@marktiderman/genesis-forms-web 0.4.0 → 0.6.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
@@ -82,6 +82,21 @@ import { Form } from "@marktiderman/genesis-forms-web";
82
82
  `dropdown` (a native select), `number` (min, max, step; the decimal keypad on phones), `date` (the
83
83
  browser's date picker) and `consent` (one checkbox). A rating can show end labels (`lowLabel`,
84
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
+ ```
85
100
  - **Submitting:** the ending shows once `onSubmit` resolves. If it rejects, the completed response
86
101
  stays on screen read-only (a completed response is final), Back is hidden, and "Try again"
87
102
  (`retryLabel`) resends the same answers.
@@ -91,7 +106,9 @@ import { Form } from "@marktiderman/genesis-forms-web";
91
106
  over.
92
107
  - **Extension ladder:** `slots` replaces a part (`header`, `brand`, `progress`, `navigation`,
93
108
  `ending`, `errorSummary`), `null` removes it, and `slotProps` feeds it (and wins over the defaults). A custom
94
- 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
95
112
  `data-gf-error-summary` (with `tabIndex={-1}`) to be focused after a failed submit.
96
113
  - **Renderers** come from a registry. `defaultRegistry` is shared by every `<Form>` without a
97
114
  `registry` prop, so prefer `extend()`, which returns a new registry and changes nothing else:
@@ -142,10 +159,49 @@ sends (it says "Wait for the photo to finish uploading."). Without `onUpload`, o
142
159
  `UploadContext` around the form, a photo field says photos can't be added here. `photoFileProblem`,
143
160
  `photoFileType` and `photoAccept` are the renderer's own checks, for a custom one.
144
161
 
162
+ `signedUploadHost` is an upload host for genesis-forms/server's `createUploadHandler`: for each file
163
+ it asks your endpoint for a signed upload, sends the bytes with `send`, and resolves to the
164
+ server's reference. A refusal rejects with the server's words. `url(ref)` reads a stored file
165
+ through your `readUrl`, null when it can't (for thumbnails in a staff view):
166
+
167
+ ```tsx
168
+ const host = signedUploadHost({
169
+ endpoint: `/api/upload/${slug}`,
170
+ claim: () => (editToken ? { edit: editToken } : { attemptId }), // read at each upload
171
+ // Supabase Storage; without `send`, the file is PUT to `upload.url`.
172
+ send: async (file, upload, contentType) => {
173
+ const { error } = await supabase.storage
174
+ .from(upload.bucket!)
175
+ .uploadToSignedUrl(upload.path, upload.token, file, { contentType });
176
+ if (error) throw error;
177
+ },
178
+ readUrl: async (ref) => signedReadUrl(ref), // your storage, or null
179
+ });
180
+
181
+ <Form form={feedback} onUpload={host.upload} onSubmit={send} />;
182
+ ```
183
+
145
184
  A **`calculated`** field shows the value the engine worked out from other answers, read-only, in an
146
185
  `<output>` named by the question that announces changes politely. It follows the field's `format`
147
186
  and `decimals`, and shows a dash until there is a value.
148
187
 
188
+ ## Clean form links
189
+
190
+ A link's query (`?edit=`, prefilled answers, `utm_…`) can be taken out of the address bar once the
191
+ page has read it, so it stays out of screenshots and shared addresses. Cosmetic, not security: the
192
+ server still checks every value. Call it once, after reading the query:
193
+
194
+ ```ts
195
+ import { hideQueryOnLoad, wasReload } from "@marktiderman/genesis-forms-web";
196
+
197
+ useEffect(() => void hideQueryOnLoad(window, wasReload()), []);
198
+ ```
199
+
200
+ It uses the browser's own `History.prototype.replaceState` (a router that patches the instance's
201
+ would otherwise re-read the address), and keeps the query in sessionStorage (`form-query:<path>`) so
202
+ a reload in the same tab within `HIDDEN_QUERY_KEEP_MS` (30 minutes) brings it back. Blocked storage
203
+ leaves the query visible.
204
+
149
205
  ## License
150
206
 
151
207
  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, HostOptions, FormState, FormProgress, FieldBlock, PhotoRef } from '@marktiderman/genesis-forms';
3
+ import { FormDefinition, FormEngine, CreateFormOptions, HostOptions, LinkParams, FormState, FormProgress, FieldBlock, PhotoRef, SignedUpload } 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. */
@@ -156,6 +167,15 @@ interface FormProps {
156
167
  * `hostOptions`).
157
168
  */
158
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;
159
179
  /** Overrides the definition's layout. */
160
180
  layout?: "focus" | "classic" | undefined;
161
181
  /**
@@ -278,6 +298,8 @@ declare const defaultRegistry: _marktiderman_genesis_forms_react.RendererRegistr
278
298
  */
279
299
  interface UploadHost {
280
300
  upload(file: File, field: FieldBlock): Promise<PhotoRef>;
301
+ /** A link to read a stored file (the host's storage signs it), or null when it can't be read. */
302
+ url?(ref: PhotoRef, field?: FieldBlock): Promise<string | null>;
281
303
  }
282
304
  /** Provide it around `<Form>` (or pass `onUpload`) so `photo` fields can store files. */
283
305
  declare const UploadContext: react.Context<UploadHost | null>;
@@ -300,6 +322,48 @@ declare function photoFileProblem(field: FieldBlock, file: {
300
322
  /** The `accept` attribute for a field: its MIME types, plus HEIC's file names for browsers that give it no type. */
301
323
  declare function photoAccept(field: FieldBlock): string;
302
324
 
325
+ /**
326
+ * Who is uploading: a new response's visit (`attemptId`, a UUID the page keeps for the
327
+ * visit), or an edit link's token while changing a sent response.
328
+ */
329
+ type UploadClaim = {
330
+ attemptId: string;
331
+ } | {
332
+ edit: string;
333
+ };
334
+ interface SignedUploadHostOptions {
335
+ /** The app's upload endpoint (`createUploadHandler`'s route), or one per field. */
336
+ endpoint: string | ((field: FieldBlock) => string);
337
+ /** Who is uploading, read at each upload. */
338
+ claim: UploadClaim | (() => UploadClaim);
339
+ /**
340
+ * Sends the bytes where the server signed. Default: PUT the file to `upload.url`
341
+ * (with its type), which a store that signs plain URLs sets. Supabase Storage:
342
+ * `supabase.storage.from(upload.bucket).uploadToSignedUrl(upload.path, upload.token, file, { contentType })`.
343
+ * A throw (or rejection) is the upload failing.
344
+ */
345
+ send?: ((file: File, upload: SignedUpload, contentType: string) => Promise<void>) | undefined;
346
+ /**
347
+ * A link to read a stored file, for thumbnails and staff views: the app's storage signs
348
+ * it. Null when the reader can't open it. Without it, `url` resolves null.
349
+ */
350
+ readUrl?: ((ref: PhotoRef, field?: FieldBlock) => Promise<string | null>) | undefined;
351
+ /** Default the global `fetch`. */
352
+ fetch?: typeof fetch | undefined;
353
+ }
354
+ /** An `UploadHost` that always has `url`. */
355
+ interface SignedUploadHost extends UploadHost {
356
+ url(ref: PhotoRef, field?: FieldBlock): Promise<string | null>;
357
+ }
358
+ /**
359
+ * The browser half of the upload handshake (genesis-forms/server's `createUploadHandler`):
360
+ * for each file it asks `endpoint` for a signed upload (`UploadRequest` as JSON), sends
361
+ * the bytes with `send`, and resolves to the reference the server handed back, which is
362
+ * all the answer keeps. A refusal rejects with the server's words. Pass it as `<Form
363
+ * onUpload={host.upload}>` or in an `UploadContext`.
364
+ */
365
+ declare function signedUploadHost(options: SignedUploadHostOptions): SignedUploadHost;
366
+
303
367
  /**
304
368
  * The video or audio a form is about, as the host app plays it. `media-notes`
305
369
  * fields read it: with `currentTime` the add row starts at the current moment
@@ -325,4 +389,29 @@ declare const SourceTextContext: react.Context<string | null>;
325
389
  /** The enclosing `SourceTextContext` value, or null. */
326
390
  declare function useSourceText(): string | null;
327
391
 
328
- export { type BrandSlotProps, CLASSIC_PROGRESS_DELAY_MS, 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, PROGRESS_DELAY_MS, type ProgressSlotProps, SourceTextContext, type ThemeColors, UPLOAD_WAIT_MESSAGE, UploadContext, type UploadHost, builtInRenderers, defaultRegistry, describedBy, photoAccept, photoFileProblem, photoFileType, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText, useUpload };
392
+ /** How long a reload brings the query back: 30 minutes. */
393
+ declare const HIDDEN_QUERY_KEEP_MS: number;
394
+ /** The parts of `window` `hideQueryOnLoad` uses (pass `window`; tests pass a fake). */
395
+ interface HiddenQueryWindow {
396
+ location: {
397
+ pathname: string;
398
+ search: string;
399
+ hash: string;
400
+ replace(url: string): void;
401
+ };
402
+ history: {
403
+ state: unknown;
404
+ replaceState(data: unknown, unused: string, url: string): void;
405
+ };
406
+ sessionStorage: Pick<Storage, "getItem" | "setItem" | "removeItem">;
407
+ }
408
+ /**
409
+ * Run once when the page has read its query. Returns "hidden" when it took the query out
410
+ * of the address bar, "restoring" when a reload is bringing a hidden query back (the page
411
+ * is about to load again), else "none".
412
+ */
413
+ declare function hideQueryOnLoad(win: HiddenQueryWindow, isReload: boolean, now?: number): "hidden" | "restoring" | "none";
414
+ /** Whether this page load was a reload (Navigation Timing), in the browser; false elsewhere. */
415
+ declare const wasReload: () => boolean;
416
+
417
+ 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, type SignedUploadHost, type SignedUploadHostOptions, SourceTextContext, type ThemeColors, UPLOAD_WAIT_MESSAGE, type UploadClaim, UploadContext, type UploadHost, builtInRenderers, defaultRegistry, describedBy, hideQueryOnLoad, photoAccept, photoFileProblem, photoFileType, signedUploadHost, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText, useUpload, wasReload };