@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 +21 -0
- package/README.md +80 -2
- package/dist/index.d.ts +175 -0
- package/dist/index.js +1024 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +8 -0
- package/package.json +62 -3
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
|
-
#
|
|
1
|
+
# @marktiderman/genesis-forms-web
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|