@asd20/ui-next 2.23.6 → 2.24.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.24.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.24.0...ui-next-v2.24.1) (2026-09-16)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * restore public form modal styling ([2119cb9](https://github.com/academydistrict20/asd20-ui-next/commit/2119cb936bf03911ce8c83f9adbbcefc81b6cb3a))
9
+
10
+ # [2.24.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.23.6...ui-next-v2.24.0) (2026-09-16)
11
+
12
+
13
+ ### Features
14
+
15
+ * add generic public form components ([12e7f88](https://github.com/academydistrict20/asd20-ui-next/commit/12e7f8812980744c46786b9effefe4cc15b5420a))
16
+
3
17
  ## [2.23.6](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.23.5...ui-next-v2.23.6) (2026-09-16)
4
18
 
5
19
 
package/components.js CHANGED
@@ -50,6 +50,7 @@ export { default as Asd20Modal } from './src/components/molecules/Asd20Modal/ind
50
50
  export { default as Asd20Notification } from './src/components/molecules/Asd20Notification/index.vue'
51
51
  export { default as Asd20Picker } from './src/components/molecules/Asd20Picker/index.vue'
52
52
  export { default as Asd20Progress } from './src/components/molecules/Asd20Progress/index.vue'
53
+ export { default as Asd20PublicFormField } from './src/components/molecules/Asd20PublicFormField/index.vue'
53
54
  export { default as Asd20SearchField } from './src/components/molecules/Asd20SearchField/index.vue'
54
55
  export { default as Asd20Share } from './src/components/molecules/Asd20Share/index.vue'
55
56
  export { default as Asd20SliderInput } from './src/components/molecules/Asd20SliderInput/index.vue'
@@ -93,6 +94,7 @@ export { default as Asd20PageHeader } from './src/components/organisms/Asd20Page
93
94
  export { default as Asd20PeopleList } from './src/components/organisms/Asd20PeopleList/index.vue'
94
95
  export { default as Asd20PersonModal } from './src/components/organisms/Asd20PersonModal/index.vue'
95
96
  export { default as Asd20PrimaryHeader } from './src/components/organisms/Asd20PrimaryHeader/index.vue'
97
+ export { default as Asd20PublicFormModal } from './src/components/organisms/Asd20PublicFormModal/index.vue'
96
98
  export { default as Asd20QuicklinksMenu } from './src/components/organisms/Asd20QuicklinksMenu/index.vue'
97
99
  export { default as Asd20SchoolHomepageHeader } from './src/components/organisms/Asd20SchoolHomepageHeader/index.vue'
98
100
  export { default as Asd20SchoolHomepageVideoHeader } from './src/components/organisms/Asd20SchoolHomepageVideoHeader/index.vue'
package/helpers.js CHANGED
@@ -36,6 +36,13 @@ export { default as organizationItemsByCategory } from './src/helpers/organizati
36
36
  export { default as organizationSubtitle } from './src/helpers/organizationSubtitle.js'
37
37
  export { default as organizationTitle } from './src/helpers/organizationTitle.js'
38
38
  export { default as preventWidows } from './src/helpers/prevent-widows.js'
39
+ export {
40
+ fetchPublicFormDefinition,
41
+ normalizeFormId,
42
+ resolvePublicFormEndpoints,
43
+ resolvePublicFormsEndpoint,
44
+ submitPublicForm,
45
+ } from './src/helpers/publicForms.js'
39
46
  export { default as queryAiSite } from './src/helpers/queryAiSite.js'
40
47
  export { default as queryDepartments } from './src/helpers/queryDepartments.js'
41
48
  export { default as queryEvents } from './src/helpers/queryEvents.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.23.6",
3
+ "version": "2.24.1",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -0,0 +1,215 @@
1
+ <template>
2
+ <div class="asd20-public-form-field">
3
+ <asd20-text-area-input
4
+ v-if="field.type === 'textarea'"
5
+ :id="inputId"
6
+ :model-value="modelValue"
7
+ :label="displayLabel"
8
+ :required="field.required"
9
+ :minlength="field.minLength"
10
+ :maxlength="field.maxLength"
11
+ :autocomplete="field.autocomplete || undefined"
12
+ :aria-describedby="describedBy"
13
+ :aria-invalid="hasErrors ? 'true' : undefined"
14
+ @update:modelValue="$emit('update:modelValue', $event)"
15
+ />
16
+
17
+ <asd20-text-input
18
+ v-else-if="textInputTypes.includes(field.type)"
19
+ :id="inputId"
20
+ :model-value="modelValue"
21
+ :label="displayLabel"
22
+ :type="field.type"
23
+ :required="field.required"
24
+ :minlength="field.minLength"
25
+ :maxlength="field.maxLength"
26
+ :autocomplete="field.autocomplete || undefined"
27
+ :aria-describedby="describedBy"
28
+ :aria-invalid="hasErrors ? 'true' : undefined"
29
+ @update:modelValue="$emit('update:modelValue', $event)"
30
+ />
31
+
32
+ <div
33
+ v-else-if="field.type === 'select'"
34
+ class="asd20-public-form-field__select"
35
+ >
36
+ <label :for="inputId">{{ displayLabel }}</label>
37
+ <select
38
+ :id="inputId"
39
+ :value="modelValue"
40
+ :required="field.required"
41
+ :aria-describedby="describedBy"
42
+ :aria-invalid="hasErrors ? 'true' : undefined"
43
+ @change="$emit('update:modelValue', $event.target.value)"
44
+ >
45
+ <option value="">
46
+ Please select
47
+ </option>
48
+ <option
49
+ v-for="option in field.options"
50
+ :key="option.value"
51
+ :value="option.value"
52
+ >
53
+ {{ option.label }}
54
+ </option>
55
+ </select>
56
+ </div>
57
+
58
+ <fieldset
59
+ v-else-if="field.type === 'radio'"
60
+ class="asd20-public-form-field__choices"
61
+ :aria-describedby="describedBy"
62
+ :aria-invalid="hasErrors ? 'true' : undefined"
63
+ >
64
+ <legend>{{ displayLabel }}</legend>
65
+ <label
66
+ v-for="option in field.options"
67
+ :key="option.value"
68
+ :for="`${inputId}-${option.value}`"
69
+ >
70
+ <input
71
+ :id="`${inputId}-${option.value}`"
72
+ :name="inputId"
73
+ type="radio"
74
+ :value="option.value"
75
+ :checked="modelValue === option.value"
76
+ :required="field.required"
77
+ @change="$emit('update:modelValue', option.value)"
78
+ />
79
+ <span>{{ option.label }}</span>
80
+ </label>
81
+ </fieldset>
82
+
83
+ <asd20-checkbox
84
+ v-else-if="field.type === 'checkbox'"
85
+ :id="inputId"
86
+ :model-value="modelValue === true"
87
+ :label="displayLabel"
88
+ checkbox
89
+ :required="field.required || undefined"
90
+ :aria-describedby="describedBy"
91
+ :aria-invalid="hasErrors ? 'true' : undefined"
92
+ @update:modelValue="$emit('update:modelValue', $event)"
93
+ />
94
+
95
+ <p
96
+ v-if="field.helpText"
97
+ :id="helpId"
98
+ class="asd20-public-form-field__help"
99
+ >
100
+ {{ field.helpText }}
101
+ </p>
102
+ <ul
103
+ v-if="hasErrors"
104
+ :id="errorId"
105
+ class="asd20-public-form-field__errors"
106
+ >
107
+ <li
108
+ v-for="error in errors"
109
+ :key="error"
110
+ >
111
+ {{ error }}
112
+ </li>
113
+ </ul>
114
+ </div>
115
+ </template>
116
+
117
+ <script>
118
+ import Asd20Checkbox from '../../atoms/Asd20Checkbox'
119
+ import Asd20TextAreaInput from '../Asd20TextAreaInput'
120
+ import Asd20TextInput from '../Asd20TextInput'
121
+
122
+ export default {
123
+ name: 'Asd20PublicFormField',
124
+ components: { Asd20Checkbox, Asd20TextAreaInput, Asd20TextInput },
125
+ props: {
126
+ field: { type: Object, required: true },
127
+ modelValue: { type: [String, Boolean], default: '' },
128
+ errors: { type: Array, default: () => [] },
129
+ inputId: { type: String, required: true },
130
+ },
131
+ emits: ['update:modelValue'],
132
+ data() {
133
+ return {
134
+ textInputTypes: ['text', 'email', 'tel', 'date'],
135
+ }
136
+ },
137
+ computed: {
138
+ displayLabel() {
139
+ return this.field.required
140
+ ? `${this.field.label} (required)`
141
+ : this.field.label
142
+ },
143
+ helpId() {
144
+ return `${this.inputId}-help`
145
+ },
146
+ errorId() {
147
+ return `${this.inputId}-error`
148
+ },
149
+ hasErrors() {
150
+ return this.errors.length > 0
151
+ },
152
+ describedBy() {
153
+ return (
154
+ [
155
+ this.field.helpText ? this.helpId : '',
156
+ this.hasErrors ? this.errorId : '',
157
+ ]
158
+ .filter(Boolean)
159
+ .join(' ') || undefined
160
+ )
161
+ },
162
+ },
163
+ }
164
+ </script>
165
+
166
+ <style lang="scss" scoped>
167
+ @use '../../../design/component-stack' as *;
168
+
169
+ .asd20-public-form-field {
170
+ margin-bottom: space(0.5);
171
+
172
+ &__select label,
173
+ legend {
174
+ color: var(--website-card__reverse-background-color);
175
+ display: block;
176
+ margin-bottom: space(0.125);
177
+ }
178
+
179
+ select {
180
+ background: white;
181
+ border: 2px solid var(--color__accent);
182
+ border-radius: var(--website-shape__radius-s);
183
+ box-sizing: border-box;
184
+ color: var(--website-card__reverse-background-color);
185
+ font: inherit;
186
+ padding: space(0.25);
187
+ width: 100%;
188
+ }
189
+
190
+ fieldset {
191
+ border: 0;
192
+ margin: 0;
193
+ padding: 0;
194
+ }
195
+
196
+ &__choices label {
197
+ align-items: center;
198
+ display: flex;
199
+ gap: space(0.25);
200
+ margin: space(0.125) 0;
201
+ }
202
+
203
+ &__help,
204
+ &__errors {
205
+ color: var(--website-card__reverse-background-color);
206
+ font-size: 0.75rem;
207
+ margin: space(0.125) 0 0;
208
+ }
209
+
210
+ &__errors {
211
+ color: asd20-swatch('emergency', -1);
212
+ padding-left: space(1);
213
+ }
214
+ }
215
+ </style>
@@ -0,0 +1,504 @@
1
+ <template>
2
+ <asd20-modal
3
+ :open="open"
4
+ :title="modalTitle"
5
+ icon="contact"
6
+ dismissable
7
+ class="asd20-public-form-modal"
8
+ @dismiss="dismiss"
9
+ >
10
+ <asd20-viewport scrollable>
11
+ <div
12
+ v-if="loadingDefinition"
13
+ class="asd20-public-form-modal__state"
14
+ role="status"
15
+ aria-live="polite"
16
+ >
17
+ <asd20-spinner size="sm" />
18
+ <span>Loading form…</span>
19
+ </div>
20
+
21
+ <div
22
+ v-else-if="definitionError"
23
+ ref="definitionError"
24
+ class="asd20-public-form-modal__feedback"
25
+ role="alert"
26
+ tabindex="-1"
27
+ >
28
+ {{ definitionError }}
29
+ </div>
30
+
31
+ <div
32
+ v-else-if="submissionSuccess"
33
+ class="asd20-public-form-modal__success"
34
+ role="status"
35
+ aria-live="polite"
36
+ >
37
+ {{ formDefinition.successMessage }}
38
+ </div>
39
+
40
+ <form
41
+ v-else-if="formDefinition"
42
+ novalidate
43
+ @submit.prevent="validateBeforeCaptcha"
44
+ >
45
+ <p
46
+ v-if="formDefinition.introduction"
47
+ class="asd20-public-form-modal__introduction"
48
+ >
49
+ {{ formDefinition.introduction }}
50
+ </p>
51
+
52
+ <div
53
+ v-if="errorEntries.length"
54
+ ref="errorSummary"
55
+ class="asd20-public-form-modal__error-summary"
56
+ role="alert"
57
+ tabindex="-1"
58
+ >
59
+ <p>Please correct the following fields:</p>
60
+ <ul>
61
+ <li
62
+ v-for="entry in errorEntries"
63
+ :key="entry.name"
64
+ >
65
+ <a
66
+ :href="`#${fieldId(entry.name)}`"
67
+ @click.prevent="focusField(entry.name)"
68
+ >{{ entry.messages[0] }}</a>
69
+ </li>
70
+ </ul>
71
+ </div>
72
+
73
+ <asd20-public-form-field
74
+ v-for="field in formDefinition.fields"
75
+ :key="field.name"
76
+ :field="field"
77
+ :input-id="fieldId(field.name)"
78
+ :model-value="values[field.name]"
79
+ :errors="validationErrors[field.name] || []"
80
+ @update:modelValue="updateValue(field.name, $event)"
81
+ />
82
+
83
+ <div
84
+ class="asd20-public-form-modal__honeypot"
85
+ aria-hidden="true"
86
+ >
87
+ <label :for="honeypotId">Website</label>
88
+ <input
89
+ :id="honeypotId"
90
+ v-model="honeypot"
91
+ type="text"
92
+ name="website"
93
+ tabindex="-1"
94
+ autocomplete="off"
95
+ />
96
+ </div>
97
+
98
+ <div
99
+ v-if="hasFallbackContact"
100
+ class="asd20-public-form-modal__fallback"
101
+ >
102
+ <p>
103
+ If you cannot submit this form, use one of these contact options:
104
+ </p>
105
+ <a
106
+ v-if="formDefinition.fallbackContact.email"
107
+ :href="`mailto:${formDefinition.fallbackContact.email}`"
108
+ >{{ formDefinition.fallbackContact.email }}</a>
109
+ <a
110
+ v-if="formDefinition.fallbackContact.phone"
111
+ :href="fallbackPhoneHref"
112
+ >{{ formDefinition.fallbackContact.phone }}</a>
113
+ </div>
114
+
115
+ <div
116
+ class="asd20-public-form-modal__submit"
117
+ aria-live="polite"
118
+ >
119
+ <template v-if="!sending">
120
+ <recaptcha
121
+ v-if="captchaEnabled"
122
+ :sitekey="captchaSitekey"
123
+ :load-recaptcha-script="loadRecaptchaScript"
124
+ size="invisible"
125
+ @verify="captchaVerified"
126
+ @error="captchaFailed"
127
+ @expired="captchaExpired"
128
+ >
129
+ <asd20-button
130
+ :label="formDefinition.submitLabel"
131
+ horizontal
132
+ centered
133
+ bordered
134
+ @click="validateBeforeCaptcha"
135
+ />
136
+ </recaptcha>
137
+ <asd20-button
138
+ v-else
139
+ :label="formDefinition.submitLabel"
140
+ horizontal
141
+ centered
142
+ bordered
143
+ @click="submitForm('')"
144
+ />
145
+ </template>
146
+ <asd20-spinner
147
+ v-else
148
+ size="sm"
149
+ />
150
+ </div>
151
+
152
+ <div
153
+ v-if="feedbackMessage"
154
+ ref="feedback"
155
+ class="asd20-public-form-modal__feedback"
156
+ :role="feedbackRole"
157
+ tabindex="-1"
158
+ >
159
+ {{ feedbackMessage }}
160
+ </div>
161
+ </form>
162
+ </asd20-viewport>
163
+ </asd20-modal>
164
+ </template>
165
+
166
+ <script>
167
+ import Asd20Button from '../../atoms/Asd20Button'
168
+ import Asd20Spinner from '../../atoms/Asd20Spinner'
169
+ import Asd20Viewport from '../../atoms/Asd20Viewport'
170
+ import Asd20Modal from '../../molecules/Asd20Modal'
171
+ import Asd20PublicFormField from '../../molecules/Asd20PublicFormField'
172
+ import Recaptcha from '../../utils/Recaptcha'
173
+ import {
174
+ createPublicFormValues,
175
+ normalizePublicFormDefinition,
176
+ validatePublicFormValues,
177
+ } from '../../../helpers/publicFormDefinition'
178
+ import {
179
+ fetchPublicFormDefinition,
180
+ normalizeFormId,
181
+ submitPublicForm,
182
+ } from '../../../helpers/publicForms'
183
+ import createComponentInstanceId from '../../../utils/createComponentInstanceId'
184
+
185
+ const DEFAULT_CAPTCHA_SITEKEY = '6LfidKoUAAAAAFqr3QEbia3jIkecsZyxBYlMvWrX'
186
+
187
+ export default {
188
+ name: 'Asd20PublicFormModal',
189
+ components: {
190
+ Asd20Button,
191
+ Asd20Modal,
192
+ Asd20PublicFormField,
193
+ Asd20Spinner,
194
+ Asd20Viewport,
195
+ Recaptcha,
196
+ },
197
+ props: {
198
+ open: { type: Boolean, default: false },
199
+ formId: { type: String, required: true },
200
+ definition: { type: Object, default: null },
201
+ endpoint: { type: String, default: '' },
202
+ functionsEndpoint: { type: String, default: '' },
203
+ captchaEnabled: { type: Boolean, default: true },
204
+ captchaSitekey: { type: String, default: DEFAULT_CAPTCHA_SITEKEY },
205
+ loadRecaptchaScript: { type: Boolean, default: true },
206
+ schemaClient: { type: Function, default: null },
207
+ submissionClient: { type: Function, default: null },
208
+ },
209
+ emits: ['dismiss', 'submitted', 'update:open'],
210
+ data() {
211
+ const instanceId = createComponentInstanceId('asd20-public-form')
212
+ return {
213
+ instanceId,
214
+ honeypotId: `${instanceId}-website`,
215
+ formDefinition: null,
216
+ values: {},
217
+ honeypot: '',
218
+ validationErrors: {},
219
+ validationAttempted: false,
220
+ loadingDefinition: false,
221
+ definitionError: '',
222
+ feedbackMessage: '',
223
+ feedbackRole: 'alert',
224
+ sending: false,
225
+ submissionSuccess: false,
226
+ loadSequence: 0,
227
+ }
228
+ },
229
+ computed: {
230
+ modalTitle() {
231
+ return this.formDefinition?.title || 'Public Form'
232
+ },
233
+ errorEntries() {
234
+ if (!this.formDefinition) return []
235
+ return this.formDefinition.fields
236
+ .filter(field => this.validationErrors[field.name]?.length)
237
+ .map(field => ({
238
+ name: field.name,
239
+ messages: this.validationErrors[field.name],
240
+ }))
241
+ },
242
+ hasFallbackContact() {
243
+ const fallback = this.formDefinition?.fallbackContact
244
+ return Boolean(fallback?.email || fallback?.phone)
245
+ },
246
+ fallbackPhoneHref() {
247
+ const phone = this.formDefinition?.fallbackContact?.phone || ''
248
+ return `tel:${phone.replace(/[^+\d]/g, '')}`
249
+ },
250
+ },
251
+ watch: {
252
+ open: {
253
+ immediate: true,
254
+ handler(value) {
255
+ if (value) this.prepareForm()
256
+ else this.loadSequence += 1
257
+ },
258
+ },
259
+ definition: {
260
+ deep: true,
261
+ handler() {
262
+ if (this.open) this.prepareForm()
263
+ },
264
+ },
265
+ formId() {
266
+ if (this.open) this.prepareForm()
267
+ },
268
+ },
269
+ methods: {
270
+ resolveRuntimeConfig() {
271
+ return (
272
+ this.$config ||
273
+ this.$?.appContext?.config?.globalProperties?.$config ||
274
+ {}
275
+ )
276
+ },
277
+ clientOptions() {
278
+ return {
279
+ endpoint: this.endpoint,
280
+ functionsEndpoint: this.functionsEndpoint,
281
+ runtimeConfig: this.resolveRuntimeConfig(),
282
+ }
283
+ },
284
+ async prepareForm() {
285
+ const sequence = ++this.loadSequence
286
+ this.loadingDefinition = true
287
+ this.definitionError = ''
288
+ this.feedbackMessage = ''
289
+ this.submissionSuccess = false
290
+ this.validationErrors = {}
291
+ this.validationAttempted = false
292
+ this.honeypot = ''
293
+
294
+ try {
295
+ const expectedId = normalizeFormId(this.formId)
296
+ const rawDefinition = this.definition
297
+ ? this.definition
298
+ : await (this.schemaClient || fetchPublicFormDefinition)(
299
+ expectedId,
300
+ this.clientOptions()
301
+ )
302
+ const normalized = normalizePublicFormDefinition(rawDefinition)
303
+ if (normalized.id !== expectedId)
304
+ throw new Error(
305
+ 'The public form definition does not match the request.'
306
+ )
307
+ if (sequence !== this.loadSequence) return
308
+
309
+ this.formDefinition = normalized
310
+ this.values = createPublicFormValues(normalized)
311
+ } catch (_error) {
312
+ if (sequence !== this.loadSequence) return
313
+ this.formDefinition = null
314
+ this.definitionError =
315
+ 'This form could not be loaded. Please try again later or use another contact option on this page.'
316
+ this.$nextTick(() => this.$refs.definitionError?.focus?.())
317
+ } finally {
318
+ if (sequence === this.loadSequence) this.loadingDefinition = false
319
+ }
320
+ },
321
+ fieldId(name) {
322
+ return `${this.instanceId}-${name}`
323
+ },
324
+ updateValue(name, value) {
325
+ this.values[name] = value
326
+ if (this.validationAttempted) this.validate(false)
327
+ },
328
+ validate(focusSummary = true) {
329
+ if (!this.formDefinition) return false
330
+ const result = validatePublicFormValues(this.formDefinition, this.values)
331
+ this.values = result.values
332
+ this.validationErrors = result.errors
333
+ this.validationAttempted = true
334
+
335
+ if (!result.valid && focusSummary) {
336
+ this.$nextTick(() => this.$refs.errorSummary?.focus?.())
337
+ }
338
+ return result.valid
339
+ },
340
+ validateBeforeCaptcha(event) {
341
+ if (this.sending || !this.validate()) {
342
+ event?.preventDefault?.()
343
+ return false
344
+ }
345
+ return true
346
+ },
347
+ focusField(name) {
348
+ document.getElementById(this.fieldId(name))?.focus?.()
349
+ },
350
+ captchaVerified(token) {
351
+ if (token) this.submitForm(token)
352
+ },
353
+ captchaFailed() {
354
+ this.showFeedback(
355
+ 'The verification service could not start. Please try again or use the contact options below.'
356
+ )
357
+ },
358
+ captchaExpired() {
359
+ this.showFeedback(
360
+ 'The verification expired. Please submit the form again.'
361
+ )
362
+ },
363
+ failureMessage(error) {
364
+ const status = Number(error?.response?.status || 0)
365
+ const serverMessage = error?.response?.data?.error
366
+ if (status === 400 && typeof serverMessage === 'string')
367
+ return serverMessage
368
+ if (status === 403)
369
+ return 'The verification was not accepted. Please try again.'
370
+ if (status === 429) {
371
+ return 'Too many submissions were received recently. Please wait and try again, or use the contact options below.'
372
+ }
373
+ if (status === 502) {
374
+ return 'The form could not be delivered right now. Please try again later or use the contact options below.'
375
+ }
376
+ if (status === 503) {
377
+ return 'This form is temporarily unavailable. Please use the contact options below.'
378
+ }
379
+ return 'Something went wrong while submitting the form. Please try again later or use the contact options below.'
380
+ },
381
+ showFeedback(message, role = 'alert') {
382
+ this.feedbackMessage = message
383
+ this.feedbackRole = role
384
+ this.$nextTick(() => this.$refs.feedback?.focus?.())
385
+ },
386
+ async submitForm(captchaToken = '') {
387
+ if (this.sending || !this.validate()) return
388
+ if (this.captchaEnabled && !captchaToken) {
389
+ this.showFeedback('Verification is required before submitting this form.')
390
+ return
391
+ }
392
+
393
+ this.sending = true
394
+ this.feedbackMessage = ''
395
+ try {
396
+ const payload = {
397
+ ...this.values,
398
+ website: this.honeypot,
399
+ captchaToken,
400
+ }
401
+ await (this.submissionClient || submitPublicForm)(
402
+ this.formDefinition.id,
403
+ payload,
404
+ this.clientOptions()
405
+ )
406
+ this.submissionSuccess = true
407
+ this.$emit('submitted', {
408
+ formId: this.formDefinition.id,
409
+ definitionVersion: this.formDefinition.version,
410
+ })
411
+ } catch (error) {
412
+ this.showFeedback(this.failureMessage(error))
413
+ } finally {
414
+ this.sending = false
415
+ }
416
+ },
417
+ dismiss() {
418
+ this.$emit('dismiss')
419
+ this.$emit('update:open', false)
420
+ },
421
+ },
422
+ }
423
+ </script>
424
+
425
+ <style lang="scss" scoped>
426
+ @use '../../../design/component-stack' as *;
427
+ @use '../../../design/typography' as *;
428
+
429
+ .asd20-public-form-modal {
430
+ @include typography;
431
+
432
+ & :deep(.asd20-modal__content .asd20-viewport) {
433
+ padding: space(1);
434
+ }
435
+
436
+ & :deep(.asd20-text-area-input textarea) {
437
+ font-family: Arial, sans-serif;
438
+ font-size: 1rem;
439
+ }
440
+
441
+ &__state,
442
+ &__submit {
443
+ align-items: center;
444
+ display: flex;
445
+ gap: space(0.25);
446
+ justify-content: center;
447
+ min-height: space(2);
448
+ }
449
+
450
+ &__error-summary,
451
+ &__feedback,
452
+ &__success {
453
+ font-weight: 600;
454
+ margin: space(0.5) 0;
455
+ }
456
+
457
+ &__error-summary {
458
+ border: 2px solid asd20-swatch('emergency', -1);
459
+ padding: space(0.5);
460
+
461
+ p {
462
+ margin-top: 0;
463
+ }
464
+ }
465
+
466
+ &__success {
467
+ font-size: 1.1rem;
468
+ text-align: center;
469
+ }
470
+
471
+ &__fallback {
472
+ display: flex;
473
+ flex-direction: column;
474
+ margin: space(0.5) 0;
475
+
476
+ p {
477
+ margin-bottom: space(0.125);
478
+ }
479
+ }
480
+
481
+ &__feedback {
482
+ text-align: center;
483
+ }
484
+
485
+ &__honeypot {
486
+ clip: rect(0, 0, 0, 0);
487
+ height: 1px;
488
+ margin: -1px;
489
+ overflow: hidden;
490
+ position: absolute;
491
+ width: 1px;
492
+ }
493
+ }
494
+
495
+ @media (min-width: 1024px) {
496
+ .asd20-public-form-modal {
497
+ & :deep(.asd20-modal) {
498
+ margin-top: auto;
499
+ margin-bottom: auto;
500
+ max-width: 40vw;
501
+ }
502
+ }
503
+ }
504
+ </style>
@@ -0,0 +1,236 @@
1
+ export const SUPPORTED_PUBLIC_FORM_FIELD_TYPES = Object.freeze([
2
+ 'text',
3
+ 'email',
4
+ 'tel',
5
+ 'date',
6
+ 'textarea',
7
+ 'select',
8
+ 'radio',
9
+ 'checkbox',
10
+ ])
11
+
12
+ const FIELD_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/
13
+ const FORM_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
14
+ const OPTION_VALUE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
15
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
16
+ const MAX_FIELDS = 50
17
+ const MAX_OPTIONS = 100
18
+ const MAX_TEXT_LENGTH = 10000
19
+
20
+ function boundedText(value, label, maximum, required = false) {
21
+ if (value == null && !required) return ''
22
+ if (typeof value !== 'string') throw new Error(`${label} must be text.`)
23
+ const normalized = value.trim()
24
+ if (required && !normalized) throw new Error(`${label} is required.`)
25
+ if (normalized.length > maximum) throw new Error(`${label} is too long.`)
26
+ return normalized
27
+ }
28
+
29
+ function optionalLength(value, label) {
30
+ if (value == null) return undefined
31
+ if (!Number.isInteger(value) || value < 0 || value > MAX_TEXT_LENGTH) {
32
+ throw new Error(`${label} is invalid.`)
33
+ }
34
+ return value
35
+ }
36
+
37
+ function normalizeOptions(field) {
38
+ if (!['select', 'radio'].includes(field.type)) return undefined
39
+ if (
40
+ !Array.isArray(field.options) ||
41
+ !field.options.length ||
42
+ field.options.length > MAX_OPTIONS
43
+ ) {
44
+ throw new Error(`${field.label} must have approved choices.`)
45
+ }
46
+
47
+ const seen = new Set()
48
+ return field.options.map(option => {
49
+ const label = boundedText(option?.label, 'Option label', 200, true)
50
+ const value = boundedText(option?.value, 'Option value', 100, true)
51
+ if (!OPTION_VALUE_PATTERN.test(value))
52
+ throw new Error(`${field.label} has an invalid choice ID.`)
53
+ if (seen.has(value))
54
+ throw new Error(`${field.label} has duplicate choices.`)
55
+ seen.add(value)
56
+ return { label, value }
57
+ })
58
+ }
59
+
60
+ function normalizeField(field) {
61
+ if (!field || typeof field !== 'object' || Array.isArray(field)) {
62
+ throw new Error('The public form contains an invalid field.')
63
+ }
64
+
65
+ const name = boundedText(field.name, 'Field name', 64, true)
66
+ if (!FIELD_NAME_PATTERN.test(name))
67
+ throw new Error('The public form contains an invalid field name.')
68
+
69
+ const type = boundedText(field.type, 'Field type', 30, true)
70
+ if (!SUPPORTED_PUBLIC_FORM_FIELD_TYPES.includes(type)) {
71
+ throw new Error(
72
+ `The public form contains an unsupported field type: ${type}.`
73
+ )
74
+ }
75
+
76
+ const normalized = {
77
+ name,
78
+ type,
79
+ label: boundedText(field.label, 'Field label', 200, true),
80
+ helpText: boundedText(field.helpText, 'Field help text', 2000),
81
+ required: field.required === true,
82
+ minLength: optionalLength(field.minLength, 'Minimum length'),
83
+ maxLength: optionalLength(field.maxLength, 'Maximum length'),
84
+ autocomplete: boundedText(field.autocomplete, 'Autocomplete value', 100),
85
+ options: undefined,
86
+ }
87
+
88
+ if (
89
+ normalized.minLength != null &&
90
+ normalized.maxLength != null &&
91
+ normalized.minLength > normalized.maxLength
92
+ ) {
93
+ throw new Error(`${normalized.label} has invalid length limits.`)
94
+ }
95
+
96
+ normalized.options = normalizeOptions({
97
+ ...field,
98
+ type,
99
+ label: normalized.label,
100
+ })
101
+ return normalized
102
+ }
103
+
104
+ export function normalizePublicFormDefinition(definition) {
105
+ if (
106
+ !definition ||
107
+ typeof definition !== 'object' ||
108
+ Array.isArray(definition)
109
+ ) {
110
+ throw new Error('The public form definition is invalid.')
111
+ }
112
+ if (
113
+ !Array.isArray(definition.fields) ||
114
+ !definition.fields.length ||
115
+ definition.fields.length > MAX_FIELDS
116
+ ) {
117
+ throw new Error('The public form must contain approved fields.')
118
+ }
119
+
120
+ const fields = definition.fields.map(normalizeField)
121
+ const names = new Set(fields.map(field => field.name))
122
+ if (names.size !== fields.length)
123
+ throw new Error('The public form contains duplicate field names.')
124
+
125
+ const version = Number(definition.version)
126
+ if (!Number.isInteger(version) || version < 1)
127
+ throw new Error('The public form version is invalid.')
128
+
129
+ const fallbackContact = definition.fallbackContact
130
+ let normalizedFallback
131
+ if (fallbackContact != null) {
132
+ if (typeof fallbackContact !== 'object' || Array.isArray(fallbackContact)) {
133
+ throw new Error('The public form fallback contact is invalid.')
134
+ }
135
+ const email = boundedText(fallbackContact.email, 'Fallback email', 320)
136
+ const phone = boundedText(fallbackContact.phone, 'Fallback phone', 50)
137
+ if (email && !EMAIL_PATTERN.test(email))
138
+ throw new Error('The public form fallback email is invalid.')
139
+ normalizedFallback = { email, phone }
140
+ }
141
+
142
+ const id = boundedText(definition.id, 'Form ID', 100, true)
143
+ if (!FORM_ID_PATTERN.test(id)) throw new Error('The public form ID is invalid.')
144
+
145
+ return {
146
+ id,
147
+ version,
148
+ title: boundedText(definition.title, 'Form title', 200, true),
149
+ introduction: boundedText(
150
+ definition.introduction,
151
+ 'Form introduction',
152
+ 2000
153
+ ),
154
+ submitLabel: boundedText(definition.submitLabel, 'Submit label', 100, true),
155
+ successMessage: boundedText(
156
+ definition.successMessage,
157
+ 'Success message',
158
+ 1000,
159
+ true
160
+ ),
161
+ fallbackContact: normalizedFallback,
162
+ fields,
163
+ }
164
+ }
165
+
166
+ export function createPublicFormValues(definition) {
167
+ return Object.fromEntries(
168
+ definition.fields.map(field => [
169
+ field.name,
170
+ field.type === 'checkbox' ? false : '',
171
+ ])
172
+ )
173
+ }
174
+
175
+ function validDate(value) {
176
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
177
+ const [year, month, day] = value.split('-').map(Number)
178
+ const date = new Date(Date.UTC(year, month - 1, day))
179
+ return (
180
+ date.getUTCFullYear() === year &&
181
+ date.getUTCMonth() === month - 1 &&
182
+ date.getUTCDate() === day
183
+ )
184
+ }
185
+
186
+ export function validatePublicFormValues(definition, sourceValues = {}) {
187
+ const errors = {}
188
+ const values = {}
189
+
190
+ for (const field of definition.fields) {
191
+ const rawValue = sourceValues[field.name]
192
+ const fieldErrors = []
193
+
194
+ if (field.type === 'checkbox') {
195
+ const value = rawValue === true
196
+ values[field.name] = value
197
+ if (field.required && !value)
198
+ fieldErrors.push(`${field.label} is required.`)
199
+ } else {
200
+ const hasInvalidType = rawValue != null && typeof rawValue !== 'string'
201
+ const value = typeof rawValue === 'string' ? rawValue.trim() : ''
202
+ values[field.name] = field.type === 'email' ? value.toLowerCase() : value
203
+
204
+ if (hasInvalidType) fieldErrors.push(`${field.label} must be text.`)
205
+ else if (field.required && !value)
206
+ fieldErrors.push(`${field.label} is required.`)
207
+ if (value && field.minLength != null && value.length < field.minLength) {
208
+ fieldErrors.push(
209
+ `${field.label} must be at least ${field.minLength} characters.`
210
+ )
211
+ }
212
+ if (value && field.maxLength != null && value.length > field.maxLength) {
213
+ fieldErrors.push(
214
+ `${field.label} must be ${field.maxLength} characters or fewer.`
215
+ )
216
+ }
217
+ if (value && field.type === 'email' && !EMAIL_PATTERN.test(value)) {
218
+ fieldErrors.push('Enter a valid email address.')
219
+ }
220
+ if (value && field.type === 'date' && !validDate(value)) {
221
+ fieldErrors.push(`Enter a valid ${field.label.toLowerCase()}.`)
222
+ }
223
+ if (value && ['select', 'radio'].includes(field.type)) {
224
+ const allowedValues = new Set(
225
+ (field.options || []).map(option => option.value)
226
+ )
227
+ if (!allowedValues.has(value))
228
+ fieldErrors.push(`Select a valid ${field.label.toLowerCase()}.`)
229
+ }
230
+ }
231
+
232
+ if (fieldErrors.length) errors[field.name] = fieldErrors
233
+ }
234
+
235
+ return { values, errors, valid: Object.keys(errors).length === 0 }
236
+ }
@@ -0,0 +1,66 @@
1
+ import axios from 'axios'
2
+
3
+ import { normalizeBaseUrl, resolveRuntimeConfigValue } from './runtimeConfig'
4
+
5
+ const PUBLIC_FORMS_ROUTE = '/api/public-forms'
6
+ const PUBLIC_FORM_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
7
+
8
+ function normalizeFormId(formId) {
9
+ const normalized =
10
+ typeof formId === 'string' ? formId.trim().toLowerCase() : ''
11
+ if (!PUBLIC_FORM_ID_PATTERN.test(normalized)) {
12
+ throw new Error('The public form ID is invalid.')
13
+ }
14
+ return normalized
15
+ }
16
+
17
+ export function resolvePublicFormsEndpoint({
18
+ endpoint = null,
19
+ functionsEndpoint = null,
20
+ runtimeConfig = {},
21
+ } = {}) {
22
+ const explicitEndpoint = resolveRuntimeConfigValue({
23
+ explicitValue: endpoint,
24
+ runtimeConfig,
25
+ runtimeConfigKeys: ['publicFormsEndpoint'],
26
+ envKeys: ['PUBLIC_FORMS_ENDPOINT'],
27
+ })
28
+ if (explicitEndpoint) return normalizeBaseUrl(explicitEndpoint)
29
+
30
+ const resolvedFunctionsEndpoint = resolveRuntimeConfigValue({
31
+ explicitValue: functionsEndpoint,
32
+ runtimeConfig,
33
+ runtimeConfigKeys: ['functionsEndpoint'],
34
+ envKeys: ['FUNCTIONS_ENDPOINT'],
35
+ })
36
+ const baseUrl = normalizeBaseUrl(resolvedFunctionsEndpoint)
37
+ return baseUrl ? `${baseUrl}${PUBLIC_FORMS_ROUTE}` : null
38
+ }
39
+
40
+ export function resolvePublicFormEndpoints(formId, options = {}) {
41
+ const normalizedFormId = normalizeFormId(formId)
42
+ const baseEndpoint = resolvePublicFormsEndpoint(options)
43
+ if (!baseEndpoint) return { schema: null, submissions: null }
44
+
45
+ const schema = `${baseEndpoint}/${normalizedFormId}`
46
+ return { schema, submissions: `${schema}/submissions` }
47
+ }
48
+
49
+ export async function fetchPublicFormDefinition(formId, options = {}) {
50
+ const { schema } = resolvePublicFormEndpoints(formId, options)
51
+ if (!schema) throw new Error('The public forms endpoint is not configured.')
52
+
53
+ const response = await axios.get(schema)
54
+ return response.data
55
+ }
56
+
57
+ export async function submitPublicForm(formId, submission, options = {}) {
58
+ const { submissions } = resolvePublicFormEndpoints(formId, options)
59
+ if (!submissions)
60
+ throw new Error('The public forms endpoint is not configured.')
61
+
62
+ const response = await axios.post(submissions, submission)
63
+ return response.data
64
+ }
65
+
66
+ export { normalizeFormId }