@asd20/ui-next 2.23.5 → 2.24.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/CHANGELOG.md +14 -0
- package/components.js +2 -0
- package/helpers.js +7 -0
- package/package.json +1 -1
- package/src/components/molecules/Asd20PublicFormField/index.vue +215 -0
- package/src/components/organisms/Asd20AccessibilityIssueModal/index.vue +187 -22
- package/src/components/organisms/Asd20PublicFormModal/index.vue +486 -0
- package/src/helpers/publicFormDefinition.js +236 -0
- package/src/helpers/publicForms.js +66 -0
- package/src/helpers/sendAccessibilityIssue.js +42 -49
|
@@ -0,0 +1,486 @@
|
|
|
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
|
+
|
|
428
|
+
.asd20-public-form-modal {
|
|
429
|
+
& :deep(.asd20-modal__content .asd20-viewport) {
|
|
430
|
+
padding: space(1);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
&__state,
|
|
434
|
+
&__submit {
|
|
435
|
+
align-items: center;
|
|
436
|
+
display: flex;
|
|
437
|
+
gap: space(0.25);
|
|
438
|
+
justify-content: center;
|
|
439
|
+
min-height: space(2);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
&__error-summary,
|
|
443
|
+
&__feedback,
|
|
444
|
+
&__success {
|
|
445
|
+
font-weight: 600;
|
|
446
|
+
margin: space(0.5) 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
&__error-summary {
|
|
450
|
+
border: 2px solid asd20-swatch('emergency', -1);
|
|
451
|
+
padding: space(0.5);
|
|
452
|
+
|
|
453
|
+
p {
|
|
454
|
+
margin-top: 0;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
&__success {
|
|
459
|
+
font-size: 1.1rem;
|
|
460
|
+
text-align: center;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
&__fallback {
|
|
464
|
+
display: flex;
|
|
465
|
+
flex-direction: column;
|
|
466
|
+
margin: space(0.5) 0;
|
|
467
|
+
|
|
468
|
+
p {
|
|
469
|
+
margin-bottom: space(0.125);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
&__feedback {
|
|
474
|
+
text-align: center;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
&__honeypot {
|
|
478
|
+
clip: rect(0, 0, 0, 0);
|
|
479
|
+
height: 1px;
|
|
480
|
+
margin: -1px;
|
|
481
|
+
overflow: hidden;
|
|
482
|
+
position: absolute;
|
|
483
|
+
width: 1px;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
</style>
|