@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 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; a custom error summary can mark itself
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
- export { type BrandSlotProps, type EndingSlotProps, type ErrorSummaryItem, type ErrorSummarySlotProps, FieldChrome, type FieldChromeProps, Form, type FormBrand, type FormPresentation, type FormPresentationValue, type FormProps, type FormSlotMap, type FormTheme, type HeaderSlotProps, type MediaTime, MediaTimeContext, type NavigationSlotProps, type ProgressSlotProps, SourceTextContext, type ThemeColors, builtInRenderers, defaultRegistry, describedBy, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText };
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 };