@duffcloudservices/site-forms 0.1.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.
Files changed (51) hide show
  1. package/README.md +213 -0
  2. package/dist/DcsForm.vue.d.ts +67 -0
  3. package/dist/composables/useDcsForm.d.ts +36 -0
  4. package/dist/composables/useFormSubmission.d.ts +18 -0
  5. package/dist/composables/useFormValidation.d.ts +19 -0
  6. package/dist/fields/DcsFormCheckbox.vue.d.ts +12 -0
  7. package/dist/fields/DcsFormCheckboxGroup.vue.d.ts +12 -0
  8. package/dist/fields/DcsFormDate.vue.d.ts +14 -0
  9. package/dist/fields/DcsFormFieldWrapper.vue.d.ts +27 -0
  10. package/dist/fields/DcsFormFile.vue.d.ts +12 -0
  11. package/dist/fields/DcsFormHidden.vue.d.ts +7 -0
  12. package/dist/fields/DcsFormHtmlBlock.vue.d.ts +6 -0
  13. package/dist/fields/DcsFormRadio.vue.d.ts +12 -0
  14. package/dist/fields/DcsFormSection.vue.d.ts +21 -0
  15. package/dist/fields/DcsFormSelect.vue.d.ts +35 -0
  16. package/dist/fields/DcsFormText.vue.d.ts +34 -0
  17. package/dist/fields/DcsFormTextarea.vue.d.ts +34 -0
  18. package/dist/index.d.ts +22 -0
  19. package/dist/index.js +918 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/loaders/yaml.d.ts +12 -0
  22. package/dist/schema/validate.d.ts +9 -0
  23. package/dist/types.d.ts +106 -0
  24. package/package.json +73 -0
  25. package/src/DcsForm.vue +299 -0
  26. package/src/__tests__/fields.test.ts +82 -0
  27. package/src/__tests__/multi-step.test.ts +46 -0
  28. package/src/__tests__/schema.test.ts +42 -0
  29. package/src/__tests__/submission.test.ts +77 -0
  30. package/src/__tests__/visible-if.test.ts +111 -0
  31. package/src/composables/useDcsForm.ts +201 -0
  32. package/src/composables/useFormSubmission.ts +113 -0
  33. package/src/composables/useFormValidation.ts +127 -0
  34. package/src/fields/DcsFormCheckbox.vue +35 -0
  35. package/src/fields/DcsFormCheckboxGroup.vue +52 -0
  36. package/src/fields/DcsFormDate.vue +34 -0
  37. package/src/fields/DcsFormFieldWrapper.vue +39 -0
  38. package/src/fields/DcsFormFile.vue +38 -0
  39. package/src/fields/DcsFormHidden.vue +17 -0
  40. package/src/fields/DcsFormHtmlBlock.vue +19 -0
  41. package/src/fields/DcsFormRadio.vue +45 -0
  42. package/src/fields/DcsFormSection.vue +19 -0
  43. package/src/fields/DcsFormSelect.vue +62 -0
  44. package/src/fields/DcsFormText.vue +54 -0
  45. package/src/fields/DcsFormTextarea.vue +43 -0
  46. package/src/index.ts +51 -0
  47. package/src/loaders/yaml.ts +51 -0
  48. package/src/schema/form-definition.schema.json +633 -0
  49. package/src/schema/validate.ts +58 -0
  50. package/src/shims.d.ts +10 -0
  51. package/src/types.ts +140 -0
@@ -0,0 +1,201 @@
1
+ import { computed, reactive, ref, type ComputedRef, type Ref } from 'vue'
2
+ import type {
3
+ PortalFormDefinition,
4
+ PortalFormField,
5
+ PortalFormStep,
6
+ FormErrors,
7
+ FormValues,
8
+ } from '../types'
9
+ import {
10
+ hasErrors,
11
+ isFieldVisible,
12
+ validateForm,
13
+ } from './useFormValidation'
14
+
15
+ export interface UseDcsFormOptions {
16
+ definition: PortalFormDefinition
17
+ }
18
+
19
+ export interface UseDcsFormReturn {
20
+ values: FormValues
21
+ errors: Ref<FormErrors>
22
+ touched: Ref<Record<string, boolean>>
23
+ submitting: Ref<boolean>
24
+ submitted: Ref<boolean>
25
+ submitError: Ref<string | null>
26
+ /** All fields, with layout-only types preserved. */
27
+ fields: ComputedRef<PortalFormField[]>
28
+ /** Steps if multi-step, else null. */
29
+ steps: ComputedRef<PortalFormStep[] | null>
30
+ currentStepIndex: Ref<number>
31
+ currentStep: ComputedRef<PortalFormStep | null>
32
+ currentStepFields: ComputedRef<PortalFormField[]>
33
+ isFirstStep: ComputedRef<boolean>
34
+ isLastStep: ComputedRef<boolean>
35
+ visibleFields: ComputedRef<PortalFormField[]>
36
+ /** Sets a field value and clears that field's error. */
37
+ setValue: (id: string, value: unknown) => void
38
+ /** Marks a field as touched + revalidates only that field's row. */
39
+ touch: (id: string) => void
40
+ /** Validates the current step (multi-step) or the entire form. */
41
+ validateCurrentScope: () => boolean
42
+ validateAll: () => boolean
43
+ next: () => boolean
44
+ prev: () => void
45
+ reset: () => void
46
+ /** Returns the values that should actually be submitted (visible only). */
47
+ collectSubmissionValues: () => FormValues
48
+ }
49
+
50
+ function defaultsFor(def: PortalFormDefinition): FormValues {
51
+ const out: FormValues = {}
52
+ for (const f of def.fields) {
53
+ if (f.defaultValue !== undefined) {
54
+ out[f.id] = f.defaultValue as unknown
55
+ } else if (f.type === 'checkbox') {
56
+ out[f.id] = false
57
+ } else if (f.type === 'multiselect' || f.type === 'checkbox-group') {
58
+ out[f.id] = [] as string[]
59
+ } else {
60
+ out[f.id] = ''
61
+ }
62
+ }
63
+ return out
64
+ }
65
+
66
+ export function useDcsForm(opts: UseDcsFormOptions): UseDcsFormReturn {
67
+ const { definition } = opts
68
+ const values = reactive<FormValues>(defaultsFor(definition))
69
+ const errors = ref<FormErrors>({})
70
+ const touched = ref<Record<string, boolean>>({})
71
+ const submitting = ref(false)
72
+ const submitted = ref(false)
73
+ const submitError = ref<string | null>(null)
74
+ const currentStepIndex = ref(0)
75
+
76
+ const fields = computed(() => definition.fields)
77
+ const steps = computed<PortalFormStep[] | null>(() =>
78
+ definition.steps && definition.steps.length > 0 ? definition.steps : null,
79
+ )
80
+ const currentStep = computed<PortalFormStep | null>(() => {
81
+ const s = steps.value
82
+ return s ? (s[currentStepIndex.value] ?? null) : null
83
+ })
84
+ const currentStepFields = computed<PortalFormField[]>(() => {
85
+ const step = currentStep.value
86
+ if (!step) return definition.fields
87
+ const ids = new Set(step.fieldIds)
88
+ return definition.fields.filter((f) => ids.has(f.id))
89
+ })
90
+ const isFirstStep = computed(() => currentStepIndex.value === 0)
91
+ const isLastStep = computed(() => {
92
+ const s = steps.value
93
+ return !s || currentStepIndex.value >= s.length - 1
94
+ })
95
+ const visibleFields = computed(() =>
96
+ currentStepFields.value.filter((f) => isFieldVisible(f, values as FormValues)),
97
+ )
98
+
99
+ function setValue(id: string, value: unknown): void {
100
+ ;(values as FormValues)[id] = value
101
+ if (errors.value[id]) {
102
+ errors.value = { ...errors.value, [id]: undefined }
103
+ }
104
+ }
105
+
106
+ function touch(id: string): void {
107
+ touched.value = { ...touched.value, [id]: true }
108
+ }
109
+
110
+ function validateScope(scopeFieldIds?: string[]): boolean {
111
+ const next = validateForm(definition, values as FormValues, scopeFieldIds)
112
+ // Merge with existing errors but only for the validated scope so we
113
+ // don't wipe out errors from other steps.
114
+ if (scopeFieldIds) {
115
+ const merged = { ...errors.value }
116
+ for (const id of scopeFieldIds) {
117
+ merged[id] = next[id]
118
+ }
119
+ errors.value = merged
120
+ } else {
121
+ errors.value = next
122
+ }
123
+ return !hasErrors(errors.value)
124
+ }
125
+
126
+ function validateCurrentScope(): boolean {
127
+ const scope = currentStep.value?.fieldIds
128
+ return validateScope(scope)
129
+ }
130
+
131
+ function validateAll(): boolean {
132
+ return validateScope()
133
+ }
134
+
135
+ function next(): boolean {
136
+ if (!steps.value) return false
137
+ if (!validateCurrentScope()) return false
138
+ if (currentStepIndex.value < steps.value.length - 1) {
139
+ currentStepIndex.value++
140
+ return true
141
+ }
142
+ return false
143
+ }
144
+
145
+ function prev(): void {
146
+ if (currentStepIndex.value > 0) currentStepIndex.value--
147
+ }
148
+
149
+ function reset(): void {
150
+ const fresh = defaultsFor(definition)
151
+ for (const k of Object.keys(values as object)) {
152
+ delete (values as FormValues)[k]
153
+ }
154
+ Object.assign(values as FormValues, fresh)
155
+ errors.value = {}
156
+ touched.value = {}
157
+ submitting.value = false
158
+ submitted.value = false
159
+ submitError.value = null
160
+ currentStepIndex.value = 0
161
+ }
162
+
163
+ function collectSubmissionValues(): FormValues {
164
+ const out: FormValues = {}
165
+ for (const f of definition.fields) {
166
+ if (
167
+ f.type === 'section-heading' ||
168
+ f.type === 'html-block'
169
+ )
170
+ continue
171
+ if (!isFieldVisible(f, values as FormValues)) continue
172
+ out[f.id] = (values as FormValues)[f.id]
173
+ }
174
+ return out
175
+ }
176
+
177
+ return {
178
+ values: values as FormValues,
179
+ errors,
180
+ touched,
181
+ submitting,
182
+ submitted,
183
+ submitError,
184
+ fields,
185
+ steps,
186
+ currentStepIndex,
187
+ currentStep,
188
+ currentStepFields,
189
+ isFirstStep,
190
+ isLastStep,
191
+ visibleFields,
192
+ setValue,
193
+ touch,
194
+ validateCurrentScope,
195
+ validateAll,
196
+ next,
197
+ prev,
198
+ reset,
199
+ collectSubmissionValues,
200
+ }
201
+ }
@@ -0,0 +1,113 @@
1
+ import type {
2
+ DcsFormSubmitPayload,
3
+ DcsFormSubmitSuccess,
4
+ DcsFormSubmitError,
5
+ } from '../types'
6
+
7
+ export interface SubmitOptions {
8
+ apiBase: string
9
+ siteSlug: string
10
+ payload: DcsFormSubmitPayload
11
+ /** Number of retries on 5xx / network errors. Default 1. */
12
+ retries?: number
13
+ /** Optional fetch implementation override (tests). */
14
+ fetchImpl?: typeof fetch
15
+ }
16
+
17
+ /**
18
+ * POSTs a form submission to
19
+ * `${apiBase}/public/sites/{siteSlug}/form-submissions`.
20
+ *
21
+ * Uses JSON for plain values and `multipart/form-data` when any
22
+ * value is a `File` (file-upload fields).
23
+ */
24
+ export async function submitFormValues(
25
+ opts: SubmitOptions,
26
+ ): Promise<DcsFormSubmitSuccess> {
27
+ const { apiBase, siteSlug, payload } = opts
28
+ const retries = opts.retries ?? 1
29
+ const fetchImpl = opts.fetchImpl ?? fetch
30
+ const url = `${apiBase.replace(/\/$/, '')}/public/sites/${encodeURIComponent(
31
+ siteSlug,
32
+ )}/form-submissions`
33
+
34
+ const hasFile = Object.values(payload.values).some(
35
+ (v) => typeof File !== 'undefined' && v instanceof File,
36
+ )
37
+
38
+ let lastError: Error | undefined
39
+ let lastStatus: number | undefined
40
+ for (let attempt = 0; attempt <= retries; attempt++) {
41
+ try {
42
+ const init: RequestInit = hasFile
43
+ ? { method: 'POST', body: buildFormData(payload) }
44
+ : {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify(payload),
48
+ }
49
+ const res = await fetchImpl(url, init)
50
+ lastStatus = res.status
51
+ if (res.ok) {
52
+ const body = await safeJson(res)
53
+ return { payload, response: body }
54
+ }
55
+ // Retry only on 5xx
56
+ if (res.status < 500) {
57
+ const body = await safeText(res)
58
+ const err: DcsFormSubmitError = {
59
+ payload,
60
+ status: res.status,
61
+ error: new Error(`Submission failed (${res.status}): ${body}`),
62
+ }
63
+ throw err
64
+ }
65
+ lastError = new Error(`Server error ${res.status}`)
66
+ } catch (e) {
67
+ // If `e` is already a DcsFormSubmitError-shaped object, rethrow.
68
+ if (e && typeof e === 'object' && 'payload' in e && 'error' in e) {
69
+ throw e
70
+ }
71
+ lastError = e as Error
72
+ }
73
+ }
74
+
75
+ throw {
76
+ payload,
77
+ status: lastStatus,
78
+ error: lastError ?? new Error('Submission failed'),
79
+ } satisfies DcsFormSubmitError
80
+ }
81
+
82
+ function buildFormData(payload: DcsFormSubmitPayload): FormData {
83
+ const fd = new FormData()
84
+ fd.append('formId', payload.formId)
85
+ if (payload.captchaToken) fd.append('captchaToken', payload.captchaToken)
86
+ for (const [key, value] of Object.entries(payload.values)) {
87
+ if (value === undefined || value === null) continue
88
+ if (value instanceof File) {
89
+ fd.append(`values[${key}]`, value)
90
+ } else if (Array.isArray(value)) {
91
+ for (const item of value) fd.append(`values[${key}][]`, String(item))
92
+ } else {
93
+ fd.append(`values[${key}]`, String(value))
94
+ }
95
+ }
96
+ return fd
97
+ }
98
+
99
+ async function safeJson(res: Response): Promise<unknown> {
100
+ try {
101
+ return await res.json()
102
+ } catch {
103
+ return null
104
+ }
105
+ }
106
+
107
+ async function safeText(res: Response): Promise<string> {
108
+ try {
109
+ return await res.text()
110
+ } catch {
111
+ return ''
112
+ }
113
+ }
@@ -0,0 +1,127 @@
1
+ import type {
2
+ PortalFormDefinition,
3
+ PortalFormField,
4
+ FormErrors,
5
+ FormValues,
6
+ } from '../types'
7
+
8
+ /**
9
+ * Returns true iff `field` is currently visible given the form's
10
+ * other values. Hidden fields are never validated and never sent.
11
+ */
12
+ export function isFieldVisible(
13
+ field: PortalFormField,
14
+ values: FormValues,
15
+ ): boolean {
16
+ if (!field.visibleIf) return true
17
+ const sibling = values[field.visibleIf.fieldId]
18
+ return sibling === field.visibleIf.equals
19
+ }
20
+
21
+ /**
22
+ * Validates a single field's value. Returns an error string or
23
+ * `undefined` if valid. Layout-only field types (section-heading,
24
+ * html-block) and hidden fields never produce errors.
25
+ */
26
+ export function validateField(
27
+ field: PortalFormField,
28
+ value: unknown,
29
+ ): string | undefined {
30
+ if (
31
+ field.type === 'section-heading' ||
32
+ field.type === 'html-block' ||
33
+ field.type === 'hidden'
34
+ ) {
35
+ return undefined
36
+ }
37
+
38
+ const isEmpty =
39
+ value === undefined ||
40
+ value === null ||
41
+ value === '' ||
42
+ (Array.isArray(value) && value.length === 0)
43
+
44
+ if (field.required && isEmpty) {
45
+ return `${field.label} is required`
46
+ }
47
+ if (isEmpty) return undefined
48
+
49
+ // Type-specific built-ins
50
+ if (field.type === 'email' && typeof value === 'string') {
51
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
52
+ return `${field.label} must be a valid email address`
53
+ }
54
+ }
55
+
56
+ const v = field.validation
57
+ if (!v) return undefined
58
+
59
+ if (typeof value === 'string') {
60
+ if (v.minLength != null && value.length < v.minLength) {
61
+ return `${field.label} must be at least ${v.minLength} characters`
62
+ }
63
+ if (v.maxLength != null && value.length > v.maxLength) {
64
+ return `${field.label} must be at most ${v.maxLength} characters`
65
+ }
66
+ if (v.regex) {
67
+ try {
68
+ if (!new RegExp(v.regex).test(value)) {
69
+ return `${field.label} is invalid`
70
+ }
71
+ } catch {
72
+ // bad regex in the definition — treat as no rule
73
+ }
74
+ }
75
+ }
76
+
77
+ if (typeof value === 'number') {
78
+ if (v.min != null && value < v.min) {
79
+ return `${field.label} must be at least ${v.min}`
80
+ }
81
+ if (v.max != null && value > v.max) {
82
+ return `${field.label} must be at most ${v.max}`
83
+ }
84
+ }
85
+
86
+ if (field.type === 'file' && v.accept && v.accept.length > 0) {
87
+ const file = value as File | null
88
+ if (file && typeof File !== 'undefined' && file instanceof File) {
89
+ const accepted = v.accept.some((a) => {
90
+ if (a.startsWith('.')) {
91
+ return file.name.toLowerCase().endsWith(a.toLowerCase())
92
+ }
93
+ return file.type === a
94
+ })
95
+ if (!accepted) {
96
+ return `${field.label} must be one of: ${v.accept.join(', ')}`
97
+ }
98
+ }
99
+ }
100
+
101
+ return undefined
102
+ }
103
+
104
+ /**
105
+ * Validates an entire form. Skips fields that are not visible.
106
+ * If `fieldIds` is supplied, only those fields are validated
107
+ * (used for per-step validation in multi-step forms).
108
+ */
109
+ export function validateForm(
110
+ def: PortalFormDefinition,
111
+ values: FormValues,
112
+ fieldIds?: string[],
113
+ ): FormErrors {
114
+ const errors: FormErrors = {}
115
+ const ids = fieldIds ? new Set(fieldIds) : null
116
+ for (const field of def.fields) {
117
+ if (ids && !ids.has(field.id)) continue
118
+ if (!isFieldVisible(field, values)) continue
119
+ const err = validateField(field, values[field.id])
120
+ if (err) errors[field.id] = err
121
+ }
122
+ return errors
123
+ }
124
+
125
+ export function hasErrors(errors: FormErrors): boolean {
126
+ return Object.values(errors).some((e) => !!e)
127
+ }
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { PortalFormField } from '../types'
4
+ import DcsFormFieldWrapper from './DcsFormFieldWrapper.vue'
5
+
6
+ const props = defineProps<{
7
+ field: PortalFormField
8
+ modelValue: boolean | undefined
9
+ error?: string
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [value: boolean]
14
+ }>()
15
+
16
+ const inputId = computed(() => `field-${props.field.id}`)
17
+ </script>
18
+
19
+ <template>
20
+ <DcsFormFieldWrapper :field="field" :error="error" :input-id="inputId">
21
+ <template #label><span class="sr-only">{{ field.label }}</span></template>
22
+ <label class="dcs-form-checkbox-single" :for="inputId">
23
+ <input
24
+ :id="inputId"
25
+ type="checkbox"
26
+ :name="field.id"
27
+ :checked="!!modelValue"
28
+ :required="field.required"
29
+ :aria-invalid="!!error"
30
+ @change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
31
+ />
32
+ <span>{{ field.label }}<span v-if="field.required" aria-hidden="true"> *</span></span>
33
+ </label>
34
+ </DcsFormFieldWrapper>
35
+ </template>
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { PortalFormField } from '../types'
4
+ import DcsFormFieldWrapper from './DcsFormFieldWrapper.vue'
5
+
6
+ const props = defineProps<{
7
+ field: PortalFormField
8
+ modelValue: string[] | undefined
9
+ error?: string
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [value: string[]]
14
+ }>()
15
+
16
+ const inputId = computed(() => `field-${props.field.id}`)
17
+ const options = computed(() => props.field.options ?? [])
18
+ const current = computed(() => props.modelValue ?? [])
19
+
20
+ function toggle(value: string, checked: boolean): void {
21
+ const set = new Set(current.value)
22
+ if (checked) set.add(value)
23
+ else set.delete(value)
24
+ emit('update:modelValue', Array.from(set))
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <DcsFormFieldWrapper :field="field" :error="error" :input-id="inputId">
30
+ <fieldset
31
+ class="dcs-form-checkbox-group"
32
+ :aria-required="field.required"
33
+ :aria-invalid="!!error"
34
+ >
35
+ <legend class="sr-only">{{ field.label }}</legend>
36
+ <label
37
+ v-for="opt in options"
38
+ :key="opt.value"
39
+ class="dcs-form-checkbox"
40
+ >
41
+ <input
42
+ type="checkbox"
43
+ :name="`${field.id}[]`"
44
+ :value="opt.value"
45
+ :checked="current.includes(opt.value)"
46
+ @change="toggle(opt.value, ($event.target as HTMLInputElement).checked)"
47
+ />
48
+ <span>{{ opt.label }}</span>
49
+ </label>
50
+ </fieldset>
51
+ </DcsFormFieldWrapper>
52
+ </template>
@@ -0,0 +1,34 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { PortalFormField } from '../types'
4
+ import DcsFormFieldWrapper from './DcsFormFieldWrapper.vue'
5
+
6
+ const props = defineProps<{
7
+ field: PortalFormField
8
+ modelValue: string | undefined
9
+ error?: string
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [value: string]
14
+ blur: []
15
+ }>()
16
+
17
+ const inputId = computed(() => `field-${props.field.id}`)
18
+ </script>
19
+
20
+ <template>
21
+ <DcsFormFieldWrapper :field="field" :error="error" :input-id="inputId">
22
+ <input
23
+ :id="inputId"
24
+ class="dcs-form-input dcs-form-date"
25
+ type="date"
26
+ :name="field.id"
27
+ :required="field.required"
28
+ :aria-invalid="!!error"
29
+ :value="modelValue ?? ''"
30
+ @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
31
+ @blur="emit('blur')"
32
+ />
33
+ </DcsFormFieldWrapper>
34
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import type { PortalFormField } from '../types'
3
+
4
+ defineProps<{
5
+ field: PortalFormField
6
+ error?: string
7
+ /** Optional id to use for the input — wrappers use it for label `for=`. */
8
+ inputId?: string
9
+ }>()
10
+ </script>
11
+
12
+ <template>
13
+ <div
14
+ class="dcs-form-field"
15
+ :class="[`dcs-form-field--${field.type}`, `dcs-form-field--width-${field.width ?? 'full'}`]"
16
+ :data-form-field-key="field.id"
17
+ >
18
+ <slot name="label">
19
+ <label
20
+ v-if="field.type !== 'checkbox' && field.type !== 'hidden' && field.type !== 'section-heading' && field.type !== 'html-block'"
21
+ :for="inputId ?? `field-${field.id}`"
22
+ class="dcs-form-field__label"
23
+ >
24
+ {{ field.label }}
25
+ <span v-if="field.required" aria-hidden="true" class="dcs-form-field__required">*</span>
26
+ </label>
27
+ </slot>
28
+
29
+ <slot />
30
+
31
+ <slot name="help">
32
+ <p v-if="field.helpText" class="dcs-form-field__help">{{ field.helpText }}</p>
33
+ </slot>
34
+
35
+ <slot name="error">
36
+ <p v-if="error" class="dcs-form-field__error" role="alert">{{ error }}</p>
37
+ </slot>
38
+ </div>
39
+ </template>
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { PortalFormField } from '../types'
4
+ import DcsFormFieldWrapper from './DcsFormFieldWrapper.vue'
5
+
6
+ const props = defineProps<{
7
+ field: PortalFormField
8
+ modelValue: File | undefined
9
+ error?: string
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [value: File | undefined]
14
+ }>()
15
+
16
+ const inputId = computed(() => `field-${props.field.id}`)
17
+ const accept = computed(() => (props.field.validation?.accept ?? []).join(','))
18
+
19
+ function onChange(e: Event): void {
20
+ const target = e.target as HTMLInputElement
21
+ emit('update:modelValue', target.files?.[0])
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <DcsFormFieldWrapper :field="field" :error="error" :input-id="inputId">
27
+ <input
28
+ :id="inputId"
29
+ class="dcs-form-input dcs-form-file"
30
+ type="file"
31
+ :name="field.id"
32
+ :required="field.required"
33
+ :aria-invalid="!!error"
34
+ :accept="accept || undefined"
35
+ @change="onChange"
36
+ />
37
+ </DcsFormFieldWrapper>
38
+ </template>
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ import type { PortalFormField } from '../types'
3
+
4
+ defineProps<{
5
+ field: PortalFormField
6
+ modelValue: string | number | undefined
7
+ }>()
8
+ </script>
9
+
10
+ <template>
11
+ <input
12
+ type="hidden"
13
+ :name="field.id"
14
+ :value="modelValue ?? (field.defaultValue as string | number | undefined) ?? ''"
15
+ :data-form-field-key="field.id"
16
+ />
17
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ import type { PortalFormField } from '../types'
3
+
4
+ defineProps<{
5
+ field: PortalFormField
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <!--
11
+ `html` is sanitized server-side per the schema; rendering with v-html
12
+ is intentional. Site authors must keep portal-side sanitization on.
13
+ -->
14
+ <div
15
+ class="dcs-form-html-block"
16
+ :data-form-field-key="field.id"
17
+ v-html="field.html ?? ''"
18
+ />
19
+ </template>