@marktiderman/genesis-forms-web 0.2.0 → 0.4.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
@@ -24,7 +24,7 @@ import { Form } from "@marktiderman/genesis-forms-web";
24
24
  progress bar and Enter to continue (Ctrl/Cmd+Enter in long text; Enter on a focused radio or
25
25
  checkbox too). After a single pick (rating, NPS, yes/no, one choice) it moves on by itself
26
26
  (`autoAdvance={false}` turns it off): only a click, tap or Space counts as a pick, arrow keys just
27
- move through the options, and it never moves on from the last question, so Submit is always a
27
+ move through the options, and it never moves on from the last question, so Send is always a
28
28
  deliberate press. `classic` shows every visible question; a failed submit focuses an error summary
29
29
  above the fields that links to each invalid one.
30
30
  - **Presentation:** `presentation="embedded"` (the default) is a block inside your own page.
@@ -40,7 +40,7 @@ import { Form } from "@marktiderman/genesis-forms-web";
40
40
  button at the top-left. Each new question fades and slides in (CSS `@starting-style`, none with
41
41
  `prefers-reduced-motion`). The ending is centred and large. In classic layout, page mode is one
42
42
  scrolling page, Google Forms style: a header card with the title and description, each question
43
- in its own card (red-edged while invalid), Submit at the end.
43
+ in its own card (red-edged while invalid), Send at the end.
44
44
  `brand={{ name: "Acme", logo }}` shows an organisation's or business's logo (an image URL or an
45
45
  element; its initials without one) and name small at the top of every step and on the ending.
46
46
  Custom slots and renderers can match with `useFormPresentation()`, which returns
@@ -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
@@ -57,6 +70,18 @@ import { Form } from "@marktiderman/genesis-forms-web";
57
70
  question text), a failed Next is announced once through a live region, and the ending heading
58
71
  takes focus. Choice rows are full-width labels at least 44px tall; motion respects
59
72
  `prefers-reduced-motion`. Ids are unique per form, so two forms can share a page.
73
+ - **Send word and required note:** the send button says `submitLabel` (default **Send**) in every
74
+ layout. When any question is required, the title shows "\* Required" (`requiredLabel`, default
75
+ "Required"; `null` hides it).
76
+ - **Theme:** `theme={{ accent: "#f97316", headerImage: url, headerImageAlt }}` sets the form's own
77
+ primary colour (buttons, selected options, stars, progress, focus rings) and a banner over the
78
+ title. The accent is adjusted only as far as contrast needs: at least 3:1 against the page in light
79
+ and dark, and text on it at least 4.5:1. It takes `#rgb`, `#rrggbb` or `rgb()`; anything else is
80
+ ignored. `themeColors(accent)` returns the colours it would use.
81
+ - **Question types:** besides text, email, phone, rating, NPS, choices and yes/no, there are
82
+ `dropdown` (a native select), `number` (min, max, step; the decimal keypad on phones), `date` (the
83
+ browser's date picker) and `consent` (one checkbox). A rating can show end labels (`lowLabel`,
84
+ `highLabel`) and `display: "stars"`; NPS takes the same labels.
60
85
  - **Submitting:** the ending shows once `onSubmit` resolves. If it rejects, the completed response
61
86
  stays on screen read-only (a completed response is final), Back is hidden, and "Try again"
62
87
  (`retryLabel`) resends the same answers.
@@ -96,6 +121,31 @@ choice, yes/no, and the structured types:
96
121
 
97
122
  `useMediaTime()` and `useSourceText()` read them in a custom renderer.
98
123
 
124
+ **`photo`** (a picture of broken equipment) has "Choose a photo" and, on a phone or tablet, "Take a
125
+ photo" (the camera), checks each file's type, size and the count before uploading, shows a thumbnail
126
+ (or the file's name when the browser can't draw it, such as HEIC) and removes one. The file goes to
127
+ the host, which stores it and resolves to its reference; the answer keeps only that:
128
+
129
+ ```tsx
130
+ <Form
131
+ form={feedback}
132
+ onUpload={async (file, field) => {
133
+ const { ref } = await uploadSomewhere(file, field.id); // your storage
134
+ return ref; // { id, name, type, size }
135
+ }}
136
+ onSubmit={send}
137
+ />
138
+ ```
139
+
140
+ A rejected upload shows under the field. While a photo is uploading the form neither moves on nor
141
+ sends (it says "Wait for the photo to finish uploading."). Without `onUpload`, or an
142
+ `UploadContext` around the form, a photo field says photos can't be added here. `photoFileProblem`,
143
+ `photoFileType` and `photoAccept` are the renderer's own checks, for a custom one.
144
+
145
+ A **`calculated`** field shows the value the engine worked out from other answers, read-only, in an
146
+ `<output>` named by the question that announces changes politely. It follows the field's `format`
147
+ and `decimals`, and shows a dash until there is a value.
148
+
99
149
  ## License
100
150
 
101
151
  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, 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
 
@@ -21,10 +21,56 @@ interface FormPresentationValue {
21
21
  */
22
22
  declare function useFormPresentation(): FormPresentationValue;
23
23
 
24
+ /**
25
+ * A form's own look: an accent colour and a header image. The accent becomes
26
+ * the Genesis `--primary` (and `--ring`) inside the form, so every primary
27
+ * surface follows it: buttons, selected options, stars, progress, focus rings.
28
+ */
29
+ interface FormTheme {
30
+ /**
31
+ * The accent colour, as `#rgb`, `#rrggbb` or `rgb(r, g, b)`, such as a
32
+ * brand's colour. It is adjusted only as far as needed for contrast: at
33
+ * least 3:1 against the page (light and dark), with text on it at least
34
+ * 4.5:1 (white or near-black, whichever reads better). Anything else is ignored.
35
+ */
36
+ accent?: string | undefined;
37
+ /** A banner image URL shown over the form's title. */
38
+ headerImage?: string | undefined;
39
+ /** The banner's text alternative. Default "" (decorative). */
40
+ headerImageAlt?: string | undefined;
41
+ }
42
+ /** Shadcn HSL components (`"221 83% 53%"`), as Genesis tokens hold them. */
43
+ interface ThemeColors {
44
+ primary: string;
45
+ primaryForeground: string;
46
+ }
47
+ /**
48
+ * The accent as Genesis primary colours for a light and a dark page, each
49
+ * adjusted only as far as contrast needs. Null when `accent` is not a colour
50
+ * this understands.
51
+ */
52
+ declare function themeColors(accent: string): {
53
+ light: ThemeColors;
54
+ dark: ThemeColors;
55
+ } | null;
56
+ /** The CSS that applies `theme` to the elements matching `selector`, or "" when it changes no colour. */
57
+ declare function themeCss(theme: FormTheme | undefined, selector: string): string;
58
+
24
59
  type SlotOverride<P> = ComponentType<P> | null;
25
60
  interface HeaderSlotProps {
26
61
  title: string;
27
62
  description?: string;
63
+ /** `theme.headerImage`: a banner over the title. */
64
+ image?: {
65
+ src: string;
66
+ alt: string;
67
+ } | undefined;
68
+ /**
69
+ * The note that marks required questions ("Required", drawn as "* Required"),
70
+ * set when the form has at least one required question. Empty when it has none
71
+ * or `requiredLabel` is null.
72
+ */
73
+ requiredNote?: string | undefined;
28
74
  className?: string;
29
75
  }
30
76
  interface BrandSlotProps {
@@ -100,6 +146,16 @@ interface FormProps {
100
146
  /** Bring your own engine (from `useForm`) to read state outside the form. `form` and `options` are then unused. */
101
147
  engine?: FormEngine | undefined;
102
148
  options?: CreateFormOptions | undefined;
149
+ /**
150
+ * Option lists this app supplies, by key, for choice questions whose
151
+ * options come from the app (`props.optionsFrom: { source: "host", key }`),
152
+ * such as `{ coaches: [{ value: "8c1…", label: "Ada Lovelace" }] }`. Shown
153
+ * after the question's own options. Like `options`, read when the response
154
+ * starts; unused with `engine` (pass `hostOptions` to `useForm` then). Check
155
+ * answers on the server with the same lists (`validateSubmission`'s
156
+ * `hostOptions`).
157
+ */
158
+ hostOptions?: HostOptions | undefined;
103
159
  /** Overrides the definition's layout. */
104
160
  layout?: "focus" | "classic" | undefined;
105
161
  /**
@@ -112,6 +168,13 @@ interface FormProps {
112
168
  presentation?: FormPresentation | undefined;
113
169
  /** An organisation's or business's name and logo, shown small at the top of every step and on the ending. */
114
170
  brand?: FormBrand | undefined;
171
+ /**
172
+ * Colours and a header image: `accent` replaces the primary colour (buttons,
173
+ * selected options, progress, focus ring), adjusted where needed so it keeps
174
+ * at least 3:1 contrast with the page and its text 4.5:1; `headerImage` is a
175
+ * banner over the title.
176
+ */
177
+ theme?: FormTheme | undefined;
115
178
  registry?: RendererRegistry | undefined;
116
179
  /**
117
180
  * Called once the form completes. The ending shows only after it resolves.
@@ -124,16 +187,47 @@ interface FormProps {
124
187
  * submit attempt (debounce it to save drafts).
125
188
  */
126
189
  onChange?: ((state: FormState, engine: FormEngine) => void) | undefined;
190
+ /**
191
+ * Save answers as they go: with the definition's `settings.saveProgress` on,
192
+ * called a moment after each step forward (in classic layout, a moment after
193
+ * the answers change) with how far the respondent got (`formProgress`); read
194
+ * the answers so far with `engine.answers()`. It never holds them up: it runs
195
+ * after the step, and a rejection is ignored (the next call carries every
196
+ * answer again). A pending call goes at once when the page is hidden, and none
197
+ * is made once the form is submitted.
198
+ */
199
+ onProgress?: ((engine: FormEngine, progress: FormProgress) => void | Promise<void>) | undefined;
200
+ /**
201
+ * The note under the first question while answers save as they go (only with
202
+ * `onProgress` and `settings.saveProgress`). `null` hides it. Default
203
+ * "Your answers save as you go."
204
+ */
205
+ progressNotice?: string | null | undefined;
127
206
  /**
128
207
  * In focus layout, move on after a single pick (rating, yes/no, one choice).
129
208
  * Arrow keys only move through the options; a click, tap or Space picks.
130
209
  * Never moves on from the last question. Default true.
131
210
  */
132
211
  autoAdvance?: boolean | undefined;
212
+ /** The send button's word, the same in every layout. Default "Send". */
133
213
  submitLabel?: string | undefined;
214
+ /** The button that moves to the next question in focus layout. Default "OK". */
134
215
  nextLabel?: string | undefined;
216
+ /**
217
+ * The note shown with the title when any question is required, after an
218
+ * asterisk ("* Required"). `null` hides it. Default "Required".
219
+ */
220
+ requiredLabel?: string | null | undefined;
135
221
  /** The submit button's label after `onSubmit` failed. Default "Try again". */
136
222
  retryLabel?: string | undefined;
223
+ /**
224
+ * Stores a file a respondent adds to a `photo` field and resolves to its
225
+ * reference (`{ id, name, type, size }`), which is all the answer keeps; a
226
+ * rejection shows under the field. While any upload is in flight the form
227
+ * neither moves on nor sends. Without it (and without an `UploadContext`
228
+ * above the form), photo fields say photos can't be added.
229
+ */
230
+ onUpload?: ((file: File, field: FieldBlock) => Promise<PhotoRef>) | undefined;
137
231
  slots?: {
138
232
  [K in keyof FormSlotMap]?: SlotOverride<FormSlotMap[K]>;
139
233
  } | undefined;
@@ -143,6 +237,10 @@ interface FormProps {
143
237
  className?: string | undefined;
144
238
  testID?: string | undefined;
145
239
  }
240
+ /** How long after a step forward `onProgress` waits, so quick steps save once. */
241
+ declare const PROGRESS_DELAY_MS = 400;
242
+ /** In classic layout, how long after the last answer changed. */
243
+ declare const CLASSIC_PROGRESS_DELAY_MS = 1500;
146
244
  declare function Form(props: FormProps): react.JSX.Element;
147
245
 
148
246
  interface FieldChromeProps {
@@ -173,6 +271,35 @@ declare const builtInRenderers: RendererEntry[];
173
271
  */
174
272
  declare const defaultRegistry: _marktiderman_genesis_forms_react.RendererRegistry;
175
273
 
274
+ /**
275
+ * Where a form's files go. The host stores each file (its own bucket, its own
276
+ * rules) and resolves to a reference, which is all the answer keeps; a
277
+ * rejection is shown under the field. `photo` fields read it.
278
+ */
279
+ interface UploadHost {
280
+ upload(file: File, field: FieldBlock): Promise<PhotoRef>;
281
+ }
282
+ /** Provide it around `<Form>` (or pass `onUpload`) so `photo` fields can store files. */
283
+ declare const UploadContext: react.Context<UploadHost | null>;
284
+ /** The enclosing `UploadContext` value, or null when no host stores files. */
285
+ declare function useUpload(): UploadHost | null;
286
+ /** Said when the respondent tries to move on or send while a photo is still uploading. */
287
+ declare const UPLOAD_WAIT_MESSAGE = "Wait for the photo to finish uploading.";
288
+
289
+ /** The file's type as the field judges it: some browsers give a HEIC photo no type, so its name decides. */
290
+ declare function photoFileType(file: {
291
+ name: string;
292
+ type: string;
293
+ }): string;
294
+ /** Why a picked file can't be added, or null when it can. */
295
+ declare function photoFileProblem(field: FieldBlock, file: {
296
+ name: string;
297
+ type: string;
298
+ size: number;
299
+ }): string | null;
300
+ /** The `accept` attribute for a field: its MIME types, plus HEIC's file names for browsers that give it no type. */
301
+ declare function photoAccept(field: FieldBlock): string;
302
+
176
303
  /**
177
304
  * The video or audio a form is about, as the host app plays it. `media-notes`
178
305
  * fields read it: with `currentTime` the add row starts at the current moment
@@ -198,4 +325,4 @@ declare const SourceTextContext: react.Context<string | null>;
198
325
  /** The enclosing `SourceTextContext` value, or null. */
199
326
  declare function useSourceText(): string | null;
200
327
 
201
- export { type BrandSlotProps, type EndingSlotProps, type ErrorSummaryItem, type ErrorSummarySlotProps, FieldChrome, type FieldChromeProps, Form, type FormBrand, type FormPresentation, type FormPresentationValue, type FormProps, type FormSlotMap, type HeaderSlotProps, type MediaTime, MediaTimeContext, type NavigationSlotProps, type ProgressSlotProps, SourceTextContext, builtInRenderers, defaultRegistry, describedBy, useFormPresentation, useMediaTime, useSourceText };
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 };