@duffcloudservices/site-forms 0.1.1 → 0.1.3

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 (41) hide show
  1. package/README.md +274 -260
  2. package/dist/composables/useFormSubmission.d.ts +1 -1
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +554 -398
  5. package/dist/index.js.map +1 -1
  6. package/dist/presets.d.ts +13 -0
  7. package/dist/site-forms.css +1 -0
  8. package/dist/types.d.ts +12 -2
  9. package/package.json +72 -73
  10. package/src/DcsForm.vue +295 -303
  11. package/src/__tests__/fields.test.ts +81 -82
  12. package/src/__tests__/multi-step.test.ts +45 -46
  13. package/src/__tests__/presets.test.ts +64 -0
  14. package/src/__tests__/schema.test.ts +41 -42
  15. package/src/__tests__/style-import.test.ts +9 -0
  16. package/src/__tests__/submission.test.ts +115 -77
  17. package/src/__tests__/validation.test.ts +29 -0
  18. package/src/__tests__/visible-if.test.ts +110 -111
  19. package/src/composables/useDcsForm.ts +201 -201
  20. package/src/composables/useFormSubmission.ts +113 -113
  21. package/src/composables/useFormValidation.ts +128 -127
  22. package/src/fields/DcsFormCheckbox.vue +35 -35
  23. package/src/fields/DcsFormCheckboxGroup.vue +52 -52
  24. package/src/fields/DcsFormDate.vue +34 -34
  25. package/src/fields/DcsFormFieldWrapper.vue +39 -39
  26. package/src/fields/DcsFormFile.vue +38 -38
  27. package/src/fields/DcsFormHidden.vue +17 -17
  28. package/src/fields/DcsFormHtmlBlock.vue +19 -19
  29. package/src/fields/DcsFormRadio.vue +45 -45
  30. package/src/fields/DcsFormSection.vue +19 -19
  31. package/src/fields/DcsFormSelect.vue +62 -62
  32. package/src/fields/DcsFormText.vue +54 -54
  33. package/src/fields/DcsFormTextarea.vue +43 -43
  34. package/src/index.ts +64 -51
  35. package/src/loaders/yaml.ts +51 -51
  36. package/src/presets.ts +192 -0
  37. package/src/schema/form-definition.schema.json +410 -45
  38. package/src/schema/validate.ts +58 -58
  39. package/src/shims.d.ts +10 -10
  40. package/src/style.css +256 -0
  41. package/src/types.ts +164 -140
@@ -1,113 +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
- }
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}/sites/{siteSlug}/forms/{formId}/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(/\/$/, '')}/sites/${encodeURIComponent(
31
+ siteSlug,
32
+ )}/forms/${encodeURIComponent(payload.formId)}/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
+ }
@@ -1,127 +1,128 @@
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
- }
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 normalizedValue = typeof value === 'string' ? value.trim() : value
39
+ const isEmpty =
40
+ value === undefined ||
41
+ value === null ||
42
+ normalizedValue === '' ||
43
+ (Array.isArray(value) && value.length === 0)
44
+
45
+ if (field.required && isEmpty) {
46
+ return `${field.label} is required`
47
+ }
48
+ if (isEmpty) return undefined
49
+
50
+ // Type-specific built-ins
51
+ if (field.type === 'email' && typeof normalizedValue === 'string') {
52
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedValue)) {
53
+ return `${field.label} must be a valid email address`
54
+ }
55
+ }
56
+
57
+ const v = field.validation
58
+ if (!v) return undefined
59
+
60
+ if (typeof normalizedValue === 'string') {
61
+ if (v.minLength != null && normalizedValue.length < v.minLength) {
62
+ return `${field.label} must be at least ${v.minLength} characters`
63
+ }
64
+ if (v.maxLength != null && normalizedValue.length > v.maxLength) {
65
+ return `${field.label} must be at most ${v.maxLength} characters`
66
+ }
67
+ if (v.regex) {
68
+ try {
69
+ if (!new RegExp(v.regex).test(normalizedValue)) {
70
+ return `${field.label} is invalid`
71
+ }
72
+ } catch {
73
+ // bad regex in the definition — treat as no rule
74
+ }
75
+ }
76
+ }
77
+
78
+ if (typeof value === 'number') {
79
+ if (v.min != null && value < v.min) {
80
+ return `${field.label} must be at least ${v.min}`
81
+ }
82
+ if (v.max != null && value > v.max) {
83
+ return `${field.label} must be at most ${v.max}`
84
+ }
85
+ }
86
+
87
+ if (field.type === 'file' && v.accept && v.accept.length > 0) {
88
+ const file = value as File | null
89
+ if (file && typeof File !== 'undefined' && file instanceof File) {
90
+ const accepted = v.accept.some((a) => {
91
+ if (a.startsWith('.')) {
92
+ return file.name.toLowerCase().endsWith(a.toLowerCase())
93
+ }
94
+ return file.type === a
95
+ })
96
+ if (!accepted) {
97
+ return `${field.label} must be one of: ${v.accept.join(', ')}`
98
+ }
99
+ }
100
+ }
101
+
102
+ return undefined
103
+ }
104
+
105
+ /**
106
+ * Validates an entire form. Skips fields that are not visible.
107
+ * If `fieldIds` is supplied, only those fields are validated
108
+ * (used for per-step validation in multi-step forms).
109
+ */
110
+ export function validateForm(
111
+ def: PortalFormDefinition,
112
+ values: FormValues,
113
+ fieldIds?: string[],
114
+ ): FormErrors {
115
+ const errors: FormErrors = {}
116
+ const ids = fieldIds ? new Set(fieldIds) : null
117
+ for (const field of def.fields) {
118
+ if (ids && !ids.has(field.id)) continue
119
+ if (!isFieldVisible(field, values)) continue
120
+ const err = validateField(field, values[field.id])
121
+ if (err) errors[field.id] = err
122
+ }
123
+ return errors
124
+ }
125
+
126
+ export function hasErrors(errors: FormErrors): boolean {
127
+ return Object.values(errors).some((e) => !!e)
128
+ }
@@ -1,35 +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>
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>
@@ -1,52 +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>
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>