@emdash-cms/plugin-forms 0.0.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.
@@ -0,0 +1,26 @@
1
+ ---
2
+ /**
3
+ * Standalone form component.
4
+ *
5
+ * Use this outside Portable Text content to embed a form directly.
6
+ *
7
+ * @example
8
+ * ```astro
9
+ * ---
10
+ * import { Form } from "@emdash-cms/plugin-forms/ui";
11
+ * ---
12
+ *
13
+ * <Form id="contact-form" />
14
+ * ```
15
+ */
16
+ import FormEmbed from "./FormEmbed.astro";
17
+
18
+ interface Props {
19
+ /** Form ID or slug */
20
+ id: string;
21
+ }
22
+
23
+ const { id } = Astro.props;
24
+ ---
25
+
26
+ <FormEmbed node={{ formId: id }} />
@@ -0,0 +1,301 @@
1
+ ---
2
+ /**
3
+ * Form embed component for Portable Text blocks.
4
+ *
5
+ * Server-renders the full form with all pages as <fieldset> elements.
6
+ * Without JavaScript, all pages are visible as one long form.
7
+ * The client-side script enhances with multi-page navigation, AJAX, etc.
8
+ */
9
+
10
+ interface Props {
11
+ node: { formId: string };
12
+ }
13
+
14
+ interface FormField {
15
+ id: string;
16
+ type: string;
17
+ label: string;
18
+ name: string;
19
+ placeholder?: string;
20
+ helpText?: string;
21
+ required: boolean;
22
+ validation?: {
23
+ minLength?: number;
24
+ maxLength?: number;
25
+ min?: number;
26
+ max?: number;
27
+ pattern?: string;
28
+ patternMessage?: string;
29
+ accept?: string;
30
+ maxFileSize?: number;
31
+ };
32
+ options?: Array<{ label: string; value: string }>;
33
+ defaultValue?: string;
34
+ width: "full" | "half";
35
+ condition?: { field: string; op: string; value?: string };
36
+ }
37
+
38
+ interface FormPage {
39
+ title?: string;
40
+ fields: FormField[];
41
+ }
42
+
43
+ interface FormDefinition {
44
+ name: string;
45
+ slug: string;
46
+ pages: FormPage[];
47
+ settings: {
48
+ spamProtection: string;
49
+ submitLabel: string;
50
+ nextLabel?: string;
51
+ prevLabel?: string;
52
+ };
53
+ status: string;
54
+ _turnstileSiteKey?: string | null;
55
+ }
56
+
57
+ const { node } = Astro.props;
58
+ const formId = node.formId;
59
+
60
+ // Fetch form definition server-side
61
+ const response = await fetch(
62
+ new URL("/_emdash/api/plugins/emdash-forms/definition", Astro.url),
63
+ {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json" },
66
+ body: JSON.stringify({ id: formId }),
67
+ }
68
+ );
69
+
70
+ if (!response.ok) return;
71
+
72
+ const form = (await response.json()) as FormDefinition;
73
+ if (!form || form.status !== "active") return;
74
+
75
+ const submitUrl = `/_emdash/api/plugins/emdash-forms/submit`;
76
+ const isMultiPage = form.pages.length > 1;
77
+ const turnstileSiteKey = form._turnstileSiteKey;
78
+ const hasFiles = form.pages.some((p: FormPage) =>
79
+ p.fields.some((f: FormField) => f.type === "file")
80
+ );
81
+
82
+ /** Generate an element ID for a field */
83
+ function fieldId(name: string): string {
84
+ return `${formId}-${name}`;
85
+ }
86
+ ---
87
+
88
+ <form
89
+ class="ec-form"
90
+ method="POST"
91
+ action={submitUrl}
92
+ enctype={hasFiles ? "multipart/form-data" : undefined}
93
+ data-form-id={formId}
94
+ data-ec-form
95
+ data-pages={isMultiPage ? form.pages.length : undefined}
96
+ >
97
+ {
98
+ form.pages.map((page: FormPage, pageIndex: number) => (
99
+ <fieldset
100
+ class="ec-form-page"
101
+ data-page={pageIndex}
102
+ aria-label={page.title || `Page ${pageIndex + 1}`}
103
+ >
104
+ {isMultiPage && page.title && (
105
+ <legend class="ec-form-page-title">{page.title}</legend>
106
+ )}
107
+
108
+ {page.fields.map((field: FormField) => (
109
+ <div
110
+ class:list={[
111
+ "ec-form-field",
112
+ `ec-form-field--${field.type}`,
113
+ field.width === "half" && "ec-form-field--half",
114
+ ]}
115
+ data-condition={
116
+ field.condition ? JSON.stringify(field.condition) : undefined
117
+ }
118
+ >
119
+ {field.type !== "hidden" && field.type !== "checkbox" && (
120
+ <label class="ec-form-label" for={fieldId(field.name)}>
121
+ {field.label}
122
+ {field.required && (
123
+ <span class="ec-form-required" aria-label="required">
124
+ *
125
+ </span>
126
+ )}
127
+ </label>
128
+ )}
129
+ {[
130
+ "text",
131
+ "email",
132
+ "tel",
133
+ "url",
134
+ "number",
135
+ "date",
136
+ "hidden",
137
+ ].includes(field.type) && (
138
+ <input
139
+ type={field.type as astroHTML.JSX.HTMLInputTypeAttribute}
140
+ class={field.type !== "hidden" ? "ec-form-input" : undefined}
141
+ id={fieldId(field.name)}
142
+ name={field.name}
143
+ placeholder={field.placeholder}
144
+ required={field.required}
145
+ minlength={field.validation?.minLength}
146
+ maxlength={field.validation?.maxLength}
147
+ min={field.validation?.min}
148
+ max={field.validation?.max}
149
+ pattern={field.validation?.pattern}
150
+ value={field.defaultValue}
151
+ />
152
+ )}
153
+ {field.type === "file" && (
154
+ <input
155
+ type="file"
156
+ class="ec-form-input"
157
+ id={fieldId(field.name)}
158
+ name={field.name}
159
+ required={field.required}
160
+ accept={field.validation?.accept}
161
+ />
162
+ )}
163
+ {field.type === "textarea" && (
164
+ <textarea
165
+ class="ec-form-input"
166
+ id={fieldId(field.name)}
167
+ name={field.name}
168
+ placeholder={field.placeholder}
169
+ required={field.required}
170
+ minlength={field.validation?.minLength}
171
+ maxlength={field.validation?.maxLength}
172
+ >
173
+ {field.defaultValue || ""}
174
+ </textarea>
175
+ )}
176
+ {field.type === "select" && (
177
+ <select
178
+ class="ec-form-input"
179
+ id={fieldId(field.name)}
180
+ name={field.name}
181
+ required={field.required}
182
+ >
183
+ {(field.options || []).map((o) => (
184
+ <option
185
+ value={o.value}
186
+ selected={o.value === field.defaultValue}
187
+ >
188
+ {o.label}
189
+ </option>
190
+ ))}
191
+ </select>
192
+ )}
193
+ {field.type === "radio" && (
194
+ <fieldset class="ec-form-radio-group" role="radiogroup">
195
+ {(field.options || []).map((o) => (
196
+ <label class="ec-form-radio-label">
197
+ <input
198
+ type="radio"
199
+ name={field.name}
200
+ value={o.value}
201
+ checked={o.value === field.defaultValue}
202
+ required={field.required}
203
+ />{" "}
204
+ {o.label}
205
+ </label>
206
+ ))}
207
+ </fieldset>
208
+ )}
209
+ {field.type === "checkbox" && (
210
+ <label class="ec-form-checkbox-label">
211
+ <input
212
+ type="checkbox"
213
+ class="ec-form-input"
214
+ id={fieldId(field.name)}
215
+ name={field.name}
216
+ value={field.defaultValue || "1"}
217
+ required={field.required}
218
+ />{" "}
219
+ {field.label}
220
+ </label>
221
+ )}
222
+ {field.type === "checkbox-group" && (
223
+ <fieldset class="ec-form-checkbox-group">
224
+ {(field.options || []).map((o) => (
225
+ <label class="ec-form-checkbox-label">
226
+ <input type="checkbox" name={field.name} value={o.value} />{" "}
227
+ {o.label}
228
+ </label>
229
+ ))}
230
+ </fieldset>
231
+ )}
232
+ {field.helpText && (
233
+ <span class="ec-form-help">{field.helpText}</span>
234
+ )}
235
+ <span
236
+ class="ec-form-error"
237
+ data-error-for={field.name}
238
+ aria-live="polite"
239
+ />
240
+ </div>
241
+ ))}
242
+ </fieldset>
243
+ ))
244
+ }
245
+
246
+ {
247
+ form.settings.spamProtection === "honeypot" && (
248
+ <div
249
+ class="ec-form-field"
250
+ style="position:absolute;left:-9999px;"
251
+ aria-hidden="true"
252
+ >
253
+ <label for={`${formId}-_hp`}>Leave blank</label>
254
+ <input
255
+ type="text"
256
+ id={`${formId}-_hp`}
257
+ name="_hp"
258
+ tabindex="-1"
259
+ autocomplete="off"
260
+ />
261
+ </div>
262
+ )
263
+ }
264
+
265
+ {
266
+ form.settings.spamProtection === "turnstile" && turnstileSiteKey && (
267
+ <div
268
+ class="ec-form-turnstile"
269
+ data-ec-turnstile
270
+ data-sitekey={turnstileSiteKey}
271
+ />
272
+ )
273
+ }
274
+
275
+ <input type="hidden" name="formId" value={formId} />
276
+
277
+ <div class="ec-form-nav">
278
+ <button type="button" class="ec-form-prev" data-ec-prev hidden>
279
+ {form.settings.prevLabel || "Previous"}
280
+ </button>
281
+ <button type="button" class="ec-form-next" data-ec-next hidden>
282
+ {form.settings.nextLabel || "Next"}
283
+ </button>
284
+ <button type="submit" class="ec-form-submit">
285
+ {form.settings.submitLabel || "Submit"}
286
+ </button>
287
+ </div>
288
+
289
+ {
290
+ isMultiPage && (
291
+ <div class="ec-form-progress" data-ec-progress aria-live="polite" />
292
+ )
293
+ }
294
+
295
+ <div class="ec-form-status" data-form-status aria-live="polite"></div>
296
+ </form>
297
+
298
+ <script>
299
+ import { initForms } from "@emdash-cms/plugin-forms/client";
300
+ initForms();
301
+ </script>
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Astro component exports for the forms plugin.
3
+ *
4
+ * Auto-wired via the `virtual:emdash/block-components` virtual module.
5
+ */
6
+
7
+ import FormEmbed from "./FormEmbed.astro";
8
+
9
+ export const blockComponents = {
10
+ "emdash-form": FormEmbed,
11
+ };