@marktiderman/genesis-forms-web 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Tiderman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
1
+ # @marktiderman/genesis-forms-web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The web renderer for [Genesis Forms](../forms/README.md), built on Genesis UI components and tokens.
4
+
5
+ ```tsx
6
+ import { Form } from "@marktiderman/genesis-forms-web";
7
+
8
+ <Form
9
+ form={afterClass} // a defineForm result or a canonical definition
10
+ layout="focus" // one question at a time; or "classic"
11
+ options={{ hidden: { coach: "Sam" } }}
12
+ onChange={(state) => saveDraft(state)}
13
+ onSubmit={(engine) => submit(engine.getState().events)}
14
+ />
15
+ ```
16
+
17
+ ```css
18
+ @import "tailwindcss";
19
+ @import "@marktiderman/genesis-ui/styles.css";
20
+ @import "@marktiderman/genesis-forms-web/styles.css";
21
+ ```
22
+
23
+ - **Layouts:** `focus` moves through the respondent's path one question at a time, with Back, a
24
+ progress bar and Enter to continue (Ctrl/Cmd+Enter in long text; Enter on a focused radio or
25
+ checkbox too). After a single pick (rating, NPS, yes/no, one choice) it moves on by itself
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
28
+ deliberate press. `classic` shows every visible question; a failed submit focuses an error summary
29
+ above the fields that links to each invalid one.
30
+ - **Presentation:** `presentation="embedded"` (the default) is a block inside your own page.
31
+ `presentation="page"` is the respondent's whole screen, for a QR code or a shared link, Typeform
32
+ style: it fills the viewport (`min-h-dvh`) with no card around it. In focus layout each question
33
+ sits centred in a `max-w-xl` column under a thin progress bar pinned to the top edge (it fills
34
+ with questions answered; "Question N of M" is its `aria-valuetext`). The question is large, the
35
+ form title introduces only the first question (then stays for assistive tech), and rating, NPS
36
+ and yes/no become an even grid of large targets. Single and multiple choice rows are full width
37
+ with a letter key, a filled selected state and a check mark; while focus is in the group, pressing
38
+ an option's letter picks it. OK is a large primary button with a "press Enter ↵" hint ("Ctrl +
39
+ Enter" or "⌘ + Enter" in long text) shown only on fine-pointer devices, and Back is a quiet icon
40
+ button at the top-left. Each new question fades and slides in (CSS `@starting-style`, none with
41
+ `prefers-reduced-motion`). The ending is centred and large. In classic layout, page mode is one
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.
44
+ `brand={{ name: "CrossFit Fairfield", logo }}` shows a practice's logo (an image URL or an
45
+ element; its initials without one) and name small at the top of every step and on the ending.
46
+ Custom slots and renderers can match with `useFormPresentation()`, which returns
47
+ `{ presentation, layout }`.
48
+
49
+ ```tsx
50
+ <Form form={afterClass} presentation="page" brand={{ name: "CrossFit Fairfield" }} onSubmit={send} />
51
+ ```
52
+
53
+ - **Accessible by default:** group questions are a `fieldset` with a `legend`, and rating, NPS and
54
+ yes/no are real radio groups (arrows select). Every control carries `aria-invalid`,
55
+ errors are linked with `aria-describedby`, and the NPS end labels are read with 0 and 10. In
56
+ focus layout, focus moves to each new question (the input of a text field, otherwise the visible
57
+ question text), a failed Next is announced once through a live region, and the ending heading
58
+ takes focus. Choice rows are full-width labels at least 44px tall; motion respects
59
+ `prefers-reduced-motion`. Ids are unique per form, so two forms can share a page.
60
+ - **Submitting:** the ending shows once `onSubmit` resolves. If it rejects, the completed response
61
+ stays on screen read-only (a completed response is final), Back is hidden, and "Try again"
62
+ (`retryLabel`) resends the same answers.
63
+ - **Engines:** `<Form form={def}>` owns an engine and starts a new one when `def` changes identity,
64
+ so keep the definition stable (module scope or `useMemo`). Pass `engine` (from `useForm`) to read
65
+ state outside the form; `form` and `options` are then unused, and a new `engine` starts the form
66
+ over.
67
+ - **Extension ladder:** `slots` replaces a part (`header`, `brand`, `progress`, `navigation`,
68
+ `ending`, `errorSummary`), `null` removes it, and `slotProps` feeds it (and wins over the defaults). A custom
69
+ ending can mark an element `data-gf-focus` to receive focus; a custom error summary can mark itself
70
+ `data-gf-error-summary` (with `tabIndex={-1}`) to be focused after a failed submit.
71
+ - **Renderers** come from a registry. `defaultRegistry` is shared by every `<Form>` without a
72
+ `registry` prop, so prefer `extend()`, which returns a new registry and changes nothing else:
73
+ `<Form registry={defaultRegistry.extend([{ type: "email", rank: 10, Component }])} />`.
74
+ `defaultRegistry.register(entry)` mutates the shared registry in place, for every form in the app.
75
+
76
+ Renderers: short and long text, email, phone (`type="tel"`), rating, NPS, single choice, multiple
77
+ choice, yes/no.
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,175 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ComponentType } from 'react';
3
+ import { FormDefinition, FormEngine, CreateFormOptions, FormState } from '@marktiderman/genesis-forms';
4
+ import * as _marktiderman_genesis_forms_react from '@marktiderman/genesis-forms-react';
5
+ import { RendererRegistry, FieldRendererProps, RendererEntry } from '@marktiderman/genesis-forms-react';
6
+
7
+ /**
8
+ * How `<Form>` sits on the page. `"embedded"` is a block inside your app's own
9
+ * page. `"page"` is the respondent's whole screen (a QR code, a shared link):
10
+ * it fills the viewport and draws no card around the form.
11
+ */
12
+ type FormPresentation = "embedded" | "page";
13
+ interface FormPresentationValue {
14
+ presentation: FormPresentation;
15
+ layout: "focus" | "classic";
16
+ }
17
+ /**
18
+ * The presentation and layout of the enclosing `<Form>`, so a custom slot or
19
+ * renderer can match the built-in ones (larger type and tap targets in page
20
+ * mode, for example). Outside a `<Form>` it reads embedded and classic.
21
+ */
22
+ declare function useFormPresentation(): FormPresentationValue;
23
+
24
+ type SlotOverride<P> = ComponentType<P> | null;
25
+ interface HeaderSlotProps {
26
+ title: string;
27
+ description?: string;
28
+ className?: string;
29
+ }
30
+ interface BrandSlotProps {
31
+ /** The practice or business name, shown beside the logo. */
32
+ name: string;
33
+ /** An image URL or your own element. Without one, the name's initials are shown. */
34
+ logo?: ReactNode;
35
+ className?: string;
36
+ }
37
+ interface ProgressSlotProps {
38
+ step: number;
39
+ total: number;
40
+ className?: string;
41
+ }
42
+ interface NavigationSlotProps {
43
+ canGoBack: boolean;
44
+ isLast: boolean;
45
+ /** True while `onSubmit` is running. */
46
+ busy: boolean;
47
+ /** True after `onSubmit` failed. The answers are kept, and submitting again resends them. */
48
+ failed: boolean;
49
+ onBack: () => void;
50
+ submitLabel: string;
51
+ nextLabel: string;
52
+ retryLabel: string;
53
+ /**
54
+ * The key that continues from this question ("Enter ↵", or "Ctrl + Enter" in
55
+ * long text). Set in page presentation's focus layout; the default
56
+ * navigation shows it only on devices with a fine pointer (mouse, trackpad).
57
+ */
58
+ keyHint?: string;
59
+ className?: string;
60
+ }
61
+ interface EndingSlotProps {
62
+ title: string;
63
+ description?: string;
64
+ className?: string;
65
+ }
66
+ interface ErrorSummaryItem {
67
+ /** The field's block id. */
68
+ id: string;
69
+ /** The question, with recall resolved. */
70
+ label: string;
71
+ message: string;
72
+ /** `#` + the field's element id: the control, or the fieldset of a group. */
73
+ href: string;
74
+ }
75
+ interface ErrorSummarySlotProps {
76
+ count: number;
77
+ /** Every invalid field, in form order. */
78
+ errors: ErrorSummaryItem[];
79
+ className?: string;
80
+ }
81
+ /** Every interior part of `<Form>`. Replace one with `slots`, feed one with `slotProps`, drop one with `null`. */
82
+ interface FormSlotMap {
83
+ header: HeaderSlotProps;
84
+ brand: BrandSlotProps;
85
+ progress: ProgressSlotProps;
86
+ navigation: NavigationSlotProps;
87
+ ending: EndingSlotProps;
88
+ errorSummary: ErrorSummarySlotProps;
89
+ }
90
+ interface FormBrand {
91
+ name: string;
92
+ /** An image URL or your own element (decorative: the name is the text). Without one, the name's initials are shown. */
93
+ logo?: ReactNode | undefined;
94
+ }
95
+ interface FormProps {
96
+ /** A `defineForm` result or a canonical definition. */
97
+ form: FormDefinition | {
98
+ definition: FormDefinition;
99
+ };
100
+ /** Bring your own engine (from `useForm`) to read state outside the form. `form` and `options` are then unused. */
101
+ engine?: FormEngine | undefined;
102
+ options?: CreateFormOptions | undefined;
103
+ /** Overrides the definition's layout. */
104
+ layout?: "focus" | "classic" | undefined;
105
+ /**
106
+ * `"embedded"` (default): a block inside your own page. `"page"`: the
107
+ * respondent's whole screen, for a QR code or a shared link. It fills the
108
+ * viewport with no card around it; in focus layout each question is centred
109
+ * under a thin progress bar with large type and tap targets, and in classic
110
+ * layout the questions are sections on one scrolling page.
111
+ */
112
+ presentation?: FormPresentation | undefined;
113
+ /** A practice's name and logo, shown small at the top of every step and on the ending. */
114
+ brand?: FormBrand | undefined;
115
+ registry?: RendererRegistry | undefined;
116
+ /**
117
+ * Called once the form completes. The ending shows only after it resolves.
118
+ * If it rejects, the answers stay on screen, read-only (a completed response
119
+ * is final), with an error and a "Try again" button that sends them again.
120
+ */
121
+ onSubmit?: ((engine: FormEngine) => void | Promise<void>) | undefined;
122
+ /**
123
+ * Called on every state change: an answer, a step forward or back, a
124
+ * submit attempt (debounce it to save drafts).
125
+ */
126
+ onChange?: ((state: FormState, engine: FormEngine) => void) | undefined;
127
+ /**
128
+ * In focus layout, move on after a single pick (rating, yes/no, one choice).
129
+ * Arrow keys only move through the options; a click, tap or Space picks.
130
+ * Never moves on from the last question. Default true.
131
+ */
132
+ autoAdvance?: boolean | undefined;
133
+ submitLabel?: string | undefined;
134
+ nextLabel?: string | undefined;
135
+ /** The submit button's label after `onSubmit` failed. Default "Try again". */
136
+ retryLabel?: string | undefined;
137
+ slots?: {
138
+ [K in keyof FormSlotMap]?: SlotOverride<FormSlotMap[K]>;
139
+ } | undefined;
140
+ slotProps?: {
141
+ [K in keyof FormSlotMap]?: Partial<FormSlotMap[K]>;
142
+ } | undefined;
143
+ className?: string | undefined;
144
+ testID?: string | undefined;
145
+ }
146
+ declare function Form(props: FormProps): react.JSX.Element;
147
+
148
+ interface FieldChromeProps {
149
+ props: FieldRendererProps;
150
+ children: ReactNode;
151
+ className?: string;
152
+ }
153
+ /**
154
+ * Label, description and error around a control. Group types (radio,
155
+ * checkbox list, rating, scale) get a real `fieldset`/`legend`, so a screen
156
+ * reader announces the question with every option; the fieldset carries the
157
+ * field's `inputId` (an error summary links to it) and `aria-describedby`.
158
+ * `aria-invalid` sits on the controls: ARIA 1.2 doesn't allow it on a group.
159
+ *
160
+ * The legend or label is the question text: it has `id="${inputId}-label"`
161
+ * and `tabIndex={-1}` and is marked `data-gf-question`, which the focus
162
+ * layout focuses on each new question unless the control is marked
163
+ * `data-gf-focus` (text fields mark their input, so typing just works).
164
+ */
165
+ declare function FieldChrome({ props, children, className }: FieldChromeProps): react.JSX.Element;
166
+ declare function describedBy(props: FieldRendererProps): string | undefined;
167
+ declare const builtInRenderers: RendererEntry[];
168
+ /**
169
+ * The built-in web renderers, shared by every `<Form>` that gets no
170
+ * `registry`. `extend()` returns a new registry and leaves this one alone;
171
+ * `register()` changes this one in place, for every form in the app.
172
+ */
173
+ declare const defaultRegistry: _marktiderman_genesis_forms_react.RendererRegistry;
174
+
175
+ 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 NavigationSlotProps, type ProgressSlotProps, builtInRenderers, defaultRegistry, describedBy, useFormPresentation };