@marktiderman/genesis-forms-web 0.3.0 → 0.5.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/README.md +69 -1
- package/dist/index.d.ts +114 -2
- package/dist/index.js +783 -66
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -50,6 +50,19 @@ import { Form } from "@marktiderman/genesis-forms-web";
|
|
|
50
50
|
<Form form={eventFeedback} presentation="page" brand={{ name: "Acme" }} onSubmit={send} />
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
- **Save answers as they go:** with the definition's `settings.saveProgress` on, `onProgress(engine,
|
|
54
|
+
{ step, total, fieldId })` is called a moment after each step forward (`PROGRESS_DELAY_MS`; in
|
|
55
|
+
classic layout, `CLASSIC_PROGRESS_DELAY_MS` after the answers change), with `fieldId` the
|
|
56
|
+
furthest question reached (`formProgress` from genesis-forms) and `engine.answers()` the answers
|
|
57
|
+
so far. It never holds up Next: a rejection is ignored (the next call carries every answer
|
|
58
|
+
again), a pending call goes at once on `pagehide`, and none is made once the form is submitted.
|
|
59
|
+
Under the first question a note says "Your answers save as you go." (`progressNotice`; `null`
|
|
60
|
+
hides it). Without `onProgress`, or with the setting off, nothing is saved and nothing is said.
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
<Form form={feedback} presentation="page" onProgress={(engine, p) => saveDraft(engine.answers(), p.fieldId)} onSubmit={send} />
|
|
64
|
+
```
|
|
65
|
+
|
|
53
66
|
- **Accessible by default:** group questions are a `fieldset` with a `legend`, and rating, NPS and
|
|
54
67
|
yes/no are real radio groups (arrows select). Every control carries `aria-invalid`,
|
|
55
68
|
errors are linked with `aria-describedby`, and the NPS end labels are read with 0 and 10. In
|
|
@@ -69,6 +82,21 @@ import { Form } from "@marktiderman/genesis-forms-web";
|
|
|
69
82
|
`dropdown` (a native select), `number` (min, max, step; the decimal keypad on phones), `date` (the
|
|
70
83
|
browser's date picker) and `consent` (one checkbox). A rating can show end labels (`lowLabel`,
|
|
71
84
|
`highLabel`) and `display: "stars"`; NPS takes the same labels.
|
|
85
|
+
- **Option lists:** an option's `description` is a muted line under its label and its `image` a
|
|
86
|
+
small picture beside it. A choice question with more than 10 options it shows (or with
|
|
87
|
+
`searchable: true`) gets a search box over an in-page list instead of radios, checkboxes or a
|
|
88
|
+
select: typing narrows it (`searchOptions`), arrow keys move, Enter or a tap picks, and it
|
|
89
|
+
works the same in classic and focus layouts and on a phone. A list filtered by another answer
|
|
90
|
+
(`optionsFrom.filter`) shows only the matching options and drops a pick that stops matching.
|
|
91
|
+
`linkParams` (such as `new URLSearchParams(location.search)`) preselects questions with
|
|
92
|
+
`prefill: { param }`; with `hideWhenPrefilled` a question the link answered isn't shown or
|
|
93
|
+
counted. Which options show, when to search and what a link picks are all decided in
|
|
94
|
+
genesis-forms (`visibleOptions`, `isSearchable`, `prefillFromLink`, `respondentPath`), so
|
|
95
|
+
every renderer agrees with the engine and the server:
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
<Form form={def} hostOptions={lists} linkParams={new URLSearchParams(location.search)} />
|
|
99
|
+
```
|
|
72
100
|
- **Submitting:** the ending shows once `onSubmit` resolves. If it rejects, the completed response
|
|
73
101
|
stays on screen read-only (a completed response is final), Back is hidden, and "Try again"
|
|
74
102
|
(`retryLabel`) resends the same answers.
|
|
@@ -78,7 +106,9 @@ import { Form } from "@marktiderman/genesis-forms-web";
|
|
|
78
106
|
over.
|
|
79
107
|
- **Extension ladder:** `slots` replaces a part (`header`, `brand`, `progress`, `navigation`,
|
|
80
108
|
`ending`, `errorSummary`), `null` removes it, and `slotProps` feeds it (and wins over the defaults). A custom
|
|
81
|
-
ending can mark an element `data-gf-focus` to receive focus;
|
|
109
|
+
ending can mark an element `data-gf-focus` to receive focus; `slotProps={{ ending: { actions } }}`
|
|
110
|
+
puts buttons or links (Send another response, Change your answers) in a centred row under the
|
|
111
|
+
default ending, and `DefaultEnding` is exported for a custom ending to wrap; a custom error summary can mark itself
|
|
82
112
|
`data-gf-error-summary` (with `tabIndex={-1}`) to be focused after a failed submit.
|
|
83
113
|
- **Renderers** come from a registry. `defaultRegistry` is shared by every `<Form>` without a
|
|
84
114
|
`registry` prop, so prefer `extend()`, which returns a new registry and changes nothing else:
|
|
@@ -108,10 +138,48 @@ choice, yes/no, and the structured types:
|
|
|
108
138
|
|
|
109
139
|
`useMediaTime()` and `useSourceText()` read them in a custom renderer.
|
|
110
140
|
|
|
141
|
+
**`photo`** (a picture of broken equipment) has "Choose a photo" and, on a phone or tablet, "Take a
|
|
142
|
+
photo" (the camera), checks each file's type, size and the count before uploading, shows a thumbnail
|
|
143
|
+
(or the file's name when the browser can't draw it, such as HEIC) and removes one. The file goes to
|
|
144
|
+
the host, which stores it and resolves to its reference; the answer keeps only that:
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
<Form
|
|
148
|
+
form={feedback}
|
|
149
|
+
onUpload={async (file, field) => {
|
|
150
|
+
const { ref } = await uploadSomewhere(file, field.id); // your storage
|
|
151
|
+
return ref; // { id, name, type, size }
|
|
152
|
+
}}
|
|
153
|
+
onSubmit={send}
|
|
154
|
+
/>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
A rejected upload shows under the field. While a photo is uploading the form neither moves on nor
|
|
158
|
+
sends (it says "Wait for the photo to finish uploading."). Without `onUpload`, or an
|
|
159
|
+
`UploadContext` around the form, a photo field says photos can't be added here. `photoFileProblem`,
|
|
160
|
+
`photoFileType` and `photoAccept` are the renderer's own checks, for a custom one.
|
|
161
|
+
|
|
111
162
|
A **`calculated`** field shows the value the engine worked out from other answers, read-only, in an
|
|
112
163
|
`<output>` named by the question that announces changes politely. It follows the field's `format`
|
|
113
164
|
and `decimals`, and shows a dash until there is a value.
|
|
114
165
|
|
|
166
|
+
## Clean form links
|
|
167
|
+
|
|
168
|
+
A link's query (`?edit=`, prefilled answers, `utm_…`) can be taken out of the address bar once the
|
|
169
|
+
page has read it, so it stays out of screenshots and shared addresses. Cosmetic, not security: the
|
|
170
|
+
server still checks every value. Call it once, after reading the query:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { hideQueryOnLoad, wasReload } from "@marktiderman/genesis-forms-web";
|
|
174
|
+
|
|
175
|
+
useEffect(() => void hideQueryOnLoad(window, wasReload()), []);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
It uses the browser's own `History.prototype.replaceState` (a router that patches the instance's
|
|
179
|
+
would otherwise re-read the address), and keeps the query in sessionStorage (`form-query:<path>`) so
|
|
180
|
+
a reload in the same tab within `HIDDEN_QUERY_KEEP_MS` (30 minutes) brings it back. Blocked storage
|
|
181
|
+
leaves the query visible.
|
|
182
|
+
|
|
115
183
|
## License
|
|
116
184
|
|
|
117
185
|
MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode, ComponentType } from 'react';
|
|
3
|
-
import { FormDefinition, FormEngine, CreateFormOptions, FormState } from '@marktiderman/genesis-forms';
|
|
3
|
+
import { FormDefinition, FormEngine, CreateFormOptions, HostOptions, LinkParams, FormState, FormProgress, FieldBlock, PhotoRef } from '@marktiderman/genesis-forms';
|
|
4
4
|
import * as _marktiderman_genesis_forms_react from '@marktiderman/genesis-forms-react';
|
|
5
5
|
import { RendererRegistry, FieldRendererProps, RendererEntry } from '@marktiderman/genesis-forms-react';
|
|
6
6
|
|
|
@@ -107,6 +107,12 @@ interface NavigationSlotProps {
|
|
|
107
107
|
interface EndingSlotProps {
|
|
108
108
|
title: string;
|
|
109
109
|
description?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Buttons or links under the ending, such as "Send another response" or "Change your
|
|
112
|
+
* answers": pass them through `slotProps.ending`. The default ending shows them in a
|
|
113
|
+
* centred row.
|
|
114
|
+
*/
|
|
115
|
+
actions?: ReactNode;
|
|
110
116
|
className?: string;
|
|
111
117
|
}
|
|
112
118
|
interface ErrorSummaryItem {
|
|
@@ -133,6 +139,11 @@ interface FormSlotMap {
|
|
|
133
139
|
ending: EndingSlotProps;
|
|
134
140
|
errorSummary: ErrorSummarySlotProps;
|
|
135
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* The built-in ending (a check, the title and description, then any `actions`). Exported
|
|
144
|
+
* so a custom ending slot can wrap it rather than copy it.
|
|
145
|
+
*/
|
|
146
|
+
declare function DefaultEnding({ title, description, actions, className }: EndingSlotProps): react.JSX.Element;
|
|
136
147
|
interface FormBrand {
|
|
137
148
|
name: string;
|
|
138
149
|
/** An image URL or your own element (decorative: the name is the text). Without one, the name's initials are shown. */
|
|
@@ -146,6 +157,25 @@ interface FormProps {
|
|
|
146
157
|
/** Bring your own engine (from `useForm`) to read state outside the form. `form` and `options` are then unused. */
|
|
147
158
|
engine?: FormEngine | undefined;
|
|
148
159
|
options?: CreateFormOptions | undefined;
|
|
160
|
+
/**
|
|
161
|
+
* Option lists this app supplies, by key, for choice questions whose
|
|
162
|
+
* options come from the app (`props.optionsFrom: { source: "host", key }`),
|
|
163
|
+
* such as `{ coaches: [{ value: "8c1…", label: "Ada Lovelace" }] }`. Shown
|
|
164
|
+
* after the question's own options. Like `options`, read when the response
|
|
165
|
+
* starts; unused with `engine` (pass `hostOptions` to `useForm` then). Check
|
|
166
|
+
* answers on the server with the same lists (`validateSubmission`'s
|
|
167
|
+
* `hostOptions`).
|
|
168
|
+
*/
|
|
169
|
+
hostOptions?: HostOptions | undefined;
|
|
170
|
+
/**
|
|
171
|
+
* The query parameters of the link the form was opened from, such as
|
|
172
|
+
* `new URLSearchParams(location.search)`. A choice question with
|
|
173
|
+
* `props.prefill: { param }` starts with that parameter's value picked when
|
|
174
|
+
* it is one of its options (`prefillFromLink`), and with
|
|
175
|
+
* `hideWhenPrefilled` it isn't shown at all. Read when the response starts;
|
|
176
|
+
* unused with `engine` (pass `linkParams` to `useForm` then).
|
|
177
|
+
*/
|
|
178
|
+
linkParams?: LinkParams | undefined;
|
|
149
179
|
/** Overrides the definition's layout. */
|
|
150
180
|
layout?: "focus" | "classic" | undefined;
|
|
151
181
|
/**
|
|
@@ -177,6 +207,22 @@ interface FormProps {
|
|
|
177
207
|
* submit attempt (debounce it to save drafts).
|
|
178
208
|
*/
|
|
179
209
|
onChange?: ((state: FormState, engine: FormEngine) => void) | undefined;
|
|
210
|
+
/**
|
|
211
|
+
* Save answers as they go: with the definition's `settings.saveProgress` on,
|
|
212
|
+
* called a moment after each step forward (in classic layout, a moment after
|
|
213
|
+
* the answers change) with how far the respondent got (`formProgress`); read
|
|
214
|
+
* the answers so far with `engine.answers()`. It never holds them up: it runs
|
|
215
|
+
* after the step, and a rejection is ignored (the next call carries every
|
|
216
|
+
* answer again). A pending call goes at once when the page is hidden, and none
|
|
217
|
+
* is made once the form is submitted.
|
|
218
|
+
*/
|
|
219
|
+
onProgress?: ((engine: FormEngine, progress: FormProgress) => void | Promise<void>) | undefined;
|
|
220
|
+
/**
|
|
221
|
+
* The note under the first question while answers save as they go (only with
|
|
222
|
+
* `onProgress` and `settings.saveProgress`). `null` hides it. Default
|
|
223
|
+
* "Your answers save as you go."
|
|
224
|
+
*/
|
|
225
|
+
progressNotice?: string | null | undefined;
|
|
180
226
|
/**
|
|
181
227
|
* In focus layout, move on after a single pick (rating, yes/no, one choice).
|
|
182
228
|
* Arrow keys only move through the options; a click, tap or Space picks.
|
|
@@ -194,6 +240,14 @@ interface FormProps {
|
|
|
194
240
|
requiredLabel?: string | null | undefined;
|
|
195
241
|
/** The submit button's label after `onSubmit` failed. Default "Try again". */
|
|
196
242
|
retryLabel?: string | undefined;
|
|
243
|
+
/**
|
|
244
|
+
* Stores a file a respondent adds to a `photo` field and resolves to its
|
|
245
|
+
* reference (`{ id, name, type, size }`), which is all the answer keeps; a
|
|
246
|
+
* rejection shows under the field. While any upload is in flight the form
|
|
247
|
+
* neither moves on nor sends. Without it (and without an `UploadContext`
|
|
248
|
+
* above the form), photo fields say photos can't be added.
|
|
249
|
+
*/
|
|
250
|
+
onUpload?: ((file: File, field: FieldBlock) => Promise<PhotoRef>) | undefined;
|
|
197
251
|
slots?: {
|
|
198
252
|
[K in keyof FormSlotMap]?: SlotOverride<FormSlotMap[K]>;
|
|
199
253
|
} | undefined;
|
|
@@ -203,6 +257,10 @@ interface FormProps {
|
|
|
203
257
|
className?: string | undefined;
|
|
204
258
|
testID?: string | undefined;
|
|
205
259
|
}
|
|
260
|
+
/** How long after a step forward `onProgress` waits, so quick steps save once. */
|
|
261
|
+
declare const PROGRESS_DELAY_MS = 400;
|
|
262
|
+
/** In classic layout, how long after the last answer changed. */
|
|
263
|
+
declare const CLASSIC_PROGRESS_DELAY_MS = 1500;
|
|
206
264
|
declare function Form(props: FormProps): react.JSX.Element;
|
|
207
265
|
|
|
208
266
|
interface FieldChromeProps {
|
|
@@ -233,6 +291,35 @@ declare const builtInRenderers: RendererEntry[];
|
|
|
233
291
|
*/
|
|
234
292
|
declare const defaultRegistry: _marktiderman_genesis_forms_react.RendererRegistry;
|
|
235
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Where a form's files go. The host stores each file (its own bucket, its own
|
|
296
|
+
* rules) and resolves to a reference, which is all the answer keeps; a
|
|
297
|
+
* rejection is shown under the field. `photo` fields read it.
|
|
298
|
+
*/
|
|
299
|
+
interface UploadHost {
|
|
300
|
+
upload(file: File, field: FieldBlock): Promise<PhotoRef>;
|
|
301
|
+
}
|
|
302
|
+
/** Provide it around `<Form>` (or pass `onUpload`) so `photo` fields can store files. */
|
|
303
|
+
declare const UploadContext: react.Context<UploadHost | null>;
|
|
304
|
+
/** The enclosing `UploadContext` value, or null when no host stores files. */
|
|
305
|
+
declare function useUpload(): UploadHost | null;
|
|
306
|
+
/** Said when the respondent tries to move on or send while a photo is still uploading. */
|
|
307
|
+
declare const UPLOAD_WAIT_MESSAGE = "Wait for the photo to finish uploading.";
|
|
308
|
+
|
|
309
|
+
/** The file's type as the field judges it: some browsers give a HEIC photo no type, so its name decides. */
|
|
310
|
+
declare function photoFileType(file: {
|
|
311
|
+
name: string;
|
|
312
|
+
type: string;
|
|
313
|
+
}): string;
|
|
314
|
+
/** Why a picked file can't be added, or null when it can. */
|
|
315
|
+
declare function photoFileProblem(field: FieldBlock, file: {
|
|
316
|
+
name: string;
|
|
317
|
+
type: string;
|
|
318
|
+
size: number;
|
|
319
|
+
}): string | null;
|
|
320
|
+
/** The `accept` attribute for a field: its MIME types, plus HEIC's file names for browsers that give it no type. */
|
|
321
|
+
declare function photoAccept(field: FieldBlock): string;
|
|
322
|
+
|
|
236
323
|
/**
|
|
237
324
|
* The video or audio a form is about, as the host app plays it. `media-notes`
|
|
238
325
|
* fields read it: with `currentTime` the add row starts at the current moment
|
|
@@ -258,4 +345,29 @@ declare const SourceTextContext: react.Context<string | null>;
|
|
|
258
345
|
/** The enclosing `SourceTextContext` value, or null. */
|
|
259
346
|
declare function useSourceText(): string | null;
|
|
260
347
|
|
|
261
|
-
|
|
348
|
+
/** How long a reload brings the query back: 30 minutes. */
|
|
349
|
+
declare const HIDDEN_QUERY_KEEP_MS: number;
|
|
350
|
+
/** The parts of `window` `hideQueryOnLoad` uses (pass `window`; tests pass a fake). */
|
|
351
|
+
interface HiddenQueryWindow {
|
|
352
|
+
location: {
|
|
353
|
+
pathname: string;
|
|
354
|
+
search: string;
|
|
355
|
+
hash: string;
|
|
356
|
+
replace(url: string): void;
|
|
357
|
+
};
|
|
358
|
+
history: {
|
|
359
|
+
state: unknown;
|
|
360
|
+
replaceState(data: unknown, unused: string, url: string): void;
|
|
361
|
+
};
|
|
362
|
+
sessionStorage: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Run once when the page has read its query. Returns "hidden" when it took the query out
|
|
366
|
+
* of the address bar, "restoring" when a reload is bringing a hidden query back (the page
|
|
367
|
+
* is about to load again), else "none".
|
|
368
|
+
*/
|
|
369
|
+
declare function hideQueryOnLoad(win: HiddenQueryWindow, isReload: boolean, now?: number): "hidden" | "restoring" | "none";
|
|
370
|
+
/** Whether this page load was a reload (Navigation Timing), in the browser; false elsewhere. */
|
|
371
|
+
declare const wasReload: () => boolean;
|
|
372
|
+
|
|
373
|
+
export { type BrandSlotProps, CLASSIC_PROGRESS_DELAY_MS, DefaultEnding, type EndingSlotProps, type ErrorSummaryItem, type ErrorSummarySlotProps, FieldChrome, type FieldChromeProps, Form, type FormBrand, type FormPresentation, type FormPresentationValue, type FormProps, type FormSlotMap, type FormTheme, HIDDEN_QUERY_KEEP_MS, type HeaderSlotProps, type HiddenQueryWindow, type MediaTime, MediaTimeContext, type NavigationSlotProps, PROGRESS_DELAY_MS, type ProgressSlotProps, SourceTextContext, type ThemeColors, UPLOAD_WAIT_MESSAGE, UploadContext, type UploadHost, builtInRenderers, defaultRegistry, describedBy, hideQueryOnLoad, photoAccept, photoFileProblem, photoFileType, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText, useUpload, wasReload };
|