@duffcloudservices/site-forms 0.1.4 → 0.2.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/README.md +4 -0
- package/dist/DcsForm.vue.d.ts +7 -1
- package/dist/composables/useFormValidation.d.ts +2 -2
- package/dist/fields/DcsFormFile.vue.d.ts +8 -5
- package/dist/index.js +591 -479
- package/dist/index.js.map +1 -1
- package/dist/site-forms.css +1 -1
- package/package.json +1 -1
- package/src/DcsForm.vue +9 -0
- package/src/__tests__/fields.test.ts +18 -0
- package/src/__tests__/submission.test.ts +26 -0
- package/src/__tests__/validation.test.ts +21 -1
- package/src/composables/useFormSubmission.ts +18 -4
- package/src/composables/useFormValidation.ts +65 -14
- package/src/fields/DcsFormFile.vue +127 -6
- package/src/style.css +96 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
PortalFormAttachmentPolicy,
|
|
2
3
|
PortalFormDefinition,
|
|
3
4
|
PortalFormField,
|
|
4
5
|
FormErrors,
|
|
@@ -26,6 +27,7 @@ export function isFieldVisible(
|
|
|
26
27
|
export function validateField(
|
|
27
28
|
field: PortalFormField,
|
|
28
29
|
value: unknown,
|
|
30
|
+
attachmentPolicy?: PortalFormAttachmentPolicy,
|
|
29
31
|
): string | undefined {
|
|
30
32
|
if (
|
|
31
33
|
field.type === 'section-heading' ||
|
|
@@ -54,8 +56,7 @@ export function validateField(
|
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
const v = field.validation
|
|
58
|
-
if (!v) return undefined
|
|
59
|
+
const v: NonNullable<PortalFormField['validation']> = field.validation ?? {}
|
|
59
60
|
|
|
60
61
|
if (typeof normalizedValue === 'string') {
|
|
61
62
|
if (v.minLength != null && normalizedValue.length < v.minLength) {
|
|
@@ -84,17 +85,25 @@ export function validateField(
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
if (field.type === 'file'
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
88
|
+
if (field.type === 'file') {
|
|
89
|
+
const files = filesFromValue(value)
|
|
90
|
+
const maxFiles = attachmentPolicy?.maxFiles
|
|
91
|
+
const maxFileSizeBytes = attachmentPolicy?.maxFileSizeBytes
|
|
92
|
+
const accept = v.accept && v.accept.length > 0 ? v.accept : attachmentPolicy?.accept
|
|
93
|
+
|
|
94
|
+
if (maxFiles != null && files.length > maxFiles) {
|
|
95
|
+
return `${field.label} accepts up to ${maxFiles} file${maxFiles === 1 ? '' : 's'}`
|
|
96
|
+
}
|
|
97
|
+
if (maxFileSizeBytes != null) {
|
|
98
|
+
const oversized = files.find((file) => file.size > maxFileSizeBytes)
|
|
99
|
+
if (oversized) {
|
|
100
|
+
return `${oversized.name} exceeds the ${formatBytes(maxFileSizeBytes)} limit`
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (accept && accept.length > 0) {
|
|
104
|
+
const rejected = files.find((file) => !isAcceptedFile(file, accept))
|
|
105
|
+
if (rejected) {
|
|
106
|
+
return `${rejected.name} must be one of: ${accept.join(', ')}`
|
|
98
107
|
}
|
|
99
108
|
}
|
|
100
109
|
}
|
|
@@ -117,7 +126,7 @@ export function validateForm(
|
|
|
117
126
|
for (const field of def.fields) {
|
|
118
127
|
if (ids && !ids.has(field.id)) continue
|
|
119
128
|
if (!isFieldVisible(field, values)) continue
|
|
120
|
-
const err = validateField(field, values[field.id])
|
|
129
|
+
const err = validateField(field, values[field.id], def.attachmentPolicy)
|
|
121
130
|
if (err) errors[field.id] = err
|
|
122
131
|
}
|
|
123
132
|
return errors
|
|
@@ -126,3 +135,45 @@ export function validateForm(
|
|
|
126
135
|
export function hasErrors(errors: FormErrors): boolean {
|
|
127
136
|
return Object.values(errors).some((e) => !!e)
|
|
128
137
|
}
|
|
138
|
+
|
|
139
|
+
function filesFromValue(value: unknown): File[] {
|
|
140
|
+
if (typeof File === 'undefined') {
|
|
141
|
+
return []
|
|
142
|
+
}
|
|
143
|
+
if (value instanceof File) {
|
|
144
|
+
return [value]
|
|
145
|
+
}
|
|
146
|
+
if (Array.isArray(value)) {
|
|
147
|
+
return value.filter((item): item is File => item instanceof File)
|
|
148
|
+
}
|
|
149
|
+
return []
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isAcceptedFile(file: File, accept: string[]): boolean {
|
|
153
|
+
const name = file.name.toLowerCase()
|
|
154
|
+
const contentType = file.type.toLowerCase()
|
|
155
|
+
|
|
156
|
+
return accept.some((rule) => {
|
|
157
|
+
const normalized = rule.trim().toLowerCase()
|
|
158
|
+
if (normalized === '') {
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
if (normalized.startsWith('.')) {
|
|
162
|
+
return name.endsWith(normalized)
|
|
163
|
+
}
|
|
164
|
+
if (normalized.endsWith('/*')) {
|
|
165
|
+
return contentType.startsWith(normalized.slice(0, -1))
|
|
166
|
+
}
|
|
167
|
+
return contentType === normalized
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function formatBytes(bytes: number): string {
|
|
172
|
+
if (bytes < 1024) {
|
|
173
|
+
return `${bytes} B`
|
|
174
|
+
}
|
|
175
|
+
if (bytes < 1024 * 1024) {
|
|
176
|
+
return `${Math.round(bytes / 1024)} KB`
|
|
177
|
+
}
|
|
178
|
+
return `${Math.round(bytes / (1024 * 1024))} MB`
|
|
179
|
+
}
|
|
@@ -1,38 +1,159 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed } from 'vue'
|
|
3
|
-
import type { PortalFormField } from '../types'
|
|
2
|
+
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
|
+
import type { PortalFormAttachmentPolicy, PortalFormField } from '../types'
|
|
4
4
|
import DcsFormFieldWrapper from './DcsFormFieldWrapper.vue'
|
|
5
5
|
|
|
6
6
|
const props = defineProps<{
|
|
7
7
|
field: PortalFormField
|
|
8
|
-
|
|
8
|
+
attachmentPolicy?: PortalFormAttachmentPolicy
|
|
9
|
+
modelValue: File | File[] | undefined
|
|
9
10
|
error?: string
|
|
10
11
|
}>()
|
|
11
12
|
|
|
12
13
|
const emit = defineEmits<{
|
|
13
|
-
'update:modelValue': [value: File | undefined]
|
|
14
|
+
'update:modelValue': [value: File | File[] | undefined]
|
|
14
15
|
}>()
|
|
15
16
|
|
|
16
17
|
const inputId = computed(() => `field-${props.field.id}`)
|
|
17
|
-
const
|
|
18
|
+
const inputEl = ref<HTMLInputElement | null>(null)
|
|
19
|
+
const accept = computed(() => (props.field.validation?.accept ?? props.attachmentPolicy?.accept ?? []).join(','))
|
|
20
|
+
const maxFiles = computed(() => Math.max(1, Math.floor(props.attachmentPolicy?.maxFiles ?? 1)))
|
|
21
|
+
const allowsMultiple = computed(() => maxFiles.value > 1)
|
|
22
|
+
const selectedFiles = computed(() => normalizeFiles(props.modelValue))
|
|
23
|
+
const previews = ref<Array<{ file: File; url: string; canPreview: boolean }>>([])
|
|
24
|
+
|
|
25
|
+
function normalizeFiles(value: File | File[] | undefined): File[] {
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
return value.filter(Boolean)
|
|
28
|
+
}
|
|
29
|
+
return value ? [value] : []
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function emitFiles(files: File[]): void {
|
|
33
|
+
const capped = files.slice(0, maxFiles.value)
|
|
34
|
+
emit('update:modelValue', allowsMultiple.value ? capped : capped[0])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fileKey(file: File): string {
|
|
38
|
+
return [file.name, file.type, file.size, file.lastModified].join(':')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function uniqueFiles(files: File[]): File[] {
|
|
42
|
+
const seen = new Set<string>()
|
|
43
|
+
const out: File[] = []
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
const key = fileKey(file)
|
|
46
|
+
if (seen.has(key)) {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
seen.add(key)
|
|
50
|
+
out.push(file)
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
18
54
|
|
|
19
55
|
function onChange(e: Event): void {
|
|
20
56
|
const target = e.target as HTMLInputElement
|
|
21
|
-
|
|
57
|
+
const picked = Array.from(target.files ?? []).filter((file) => file.size > 0)
|
|
58
|
+
if (picked.length === 0) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
emitFiles(allowsMultiple.value ? uniqueFiles([...selectedFiles.value, ...picked]) : picked)
|
|
62
|
+
target.value = ''
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function removeFile(index: number): void {
|
|
66
|
+
const next = selectedFiles.value.filter((_, i) => i !== index)
|
|
67
|
+
emitFiles(next)
|
|
68
|
+
if (next.length === 0 && inputEl.value) {
|
|
69
|
+
inputEl.value.value = ''
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatFileSize(bytes: number): string {
|
|
74
|
+
if (bytes < 1024) {
|
|
75
|
+
return `${bytes} B`
|
|
76
|
+
}
|
|
77
|
+
if (bytes < 1024 * 1024) {
|
|
78
|
+
return `${(bytes / 1024).toFixed(1)} KB`
|
|
79
|
+
}
|
|
80
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function revokePreviews(): void {
|
|
84
|
+
for (const preview of previews.value) {
|
|
85
|
+
URL.revokeObjectURL(preview.url)
|
|
86
|
+
}
|
|
87
|
+
previews.value = []
|
|
22
88
|
}
|
|
89
|
+
|
|
90
|
+
watch(
|
|
91
|
+
selectedFiles,
|
|
92
|
+
(files) => {
|
|
93
|
+
revokePreviews()
|
|
94
|
+
previews.value = files.map((file) => ({
|
|
95
|
+
file,
|
|
96
|
+
url: URL.createObjectURL(file),
|
|
97
|
+
canPreview: file.type.toLowerCase().startsWith('image/'),
|
|
98
|
+
}))
|
|
99
|
+
},
|
|
100
|
+
{ immediate: true },
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
onBeforeUnmount(revokePreviews)
|
|
23
104
|
</script>
|
|
24
105
|
|
|
25
106
|
<template>
|
|
26
107
|
<DcsFormFieldWrapper :field="field" :error="error" :input-id="inputId">
|
|
27
108
|
<input
|
|
109
|
+
ref="inputEl"
|
|
28
110
|
:id="inputId"
|
|
29
111
|
class="dcs-form-input dcs-form-file"
|
|
30
112
|
type="file"
|
|
31
113
|
:name="field.id"
|
|
114
|
+
:multiple="allowsMultiple"
|
|
32
115
|
:required="field.required"
|
|
33
116
|
:aria-invalid="!!error"
|
|
34
117
|
:accept="accept || undefined"
|
|
35
118
|
@change="onChange"
|
|
36
119
|
/>
|
|
120
|
+
|
|
121
|
+
<p v-if="allowsMultiple" class="dcs-form-file__status">
|
|
122
|
+
{{ selectedFiles.length }} of {{ maxFiles }} files selected
|
|
123
|
+
</p>
|
|
124
|
+
|
|
125
|
+
<div
|
|
126
|
+
v-if="previews.length > 0"
|
|
127
|
+
class="dcs-form-file-preview-grid"
|
|
128
|
+
aria-live="polite"
|
|
129
|
+
>
|
|
130
|
+
<div
|
|
131
|
+
v-for="(preview, index) in previews"
|
|
132
|
+
:key="fileKey(preview.file)"
|
|
133
|
+
class="dcs-form-file-preview"
|
|
134
|
+
>
|
|
135
|
+
<img
|
|
136
|
+
v-if="preview.canPreview"
|
|
137
|
+
class="dcs-form-file-preview__image"
|
|
138
|
+
:src="preview.url"
|
|
139
|
+
:alt="`${preview.file.name} preview`"
|
|
140
|
+
/>
|
|
141
|
+
<div v-else class="dcs-form-file-preview__placeholder" aria-hidden="true">
|
|
142
|
+
File
|
|
143
|
+
</div>
|
|
144
|
+
<div class="dcs-form-file-preview__meta">
|
|
145
|
+
<span class="dcs-form-file-preview__name">{{ preview.file.name }}</span>
|
|
146
|
+
<span class="dcs-form-file-preview__size">{{ formatFileSize(preview.file.size) }}</span>
|
|
147
|
+
</div>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
class="dcs-form-file-preview__remove"
|
|
151
|
+
:aria-label="`Remove ${preview.file.name}`"
|
|
152
|
+
@click="removeFile(index)"
|
|
153
|
+
>
|
|
154
|
+
Remove
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
37
158
|
</DcsFormFieldWrapper>
|
|
38
159
|
</template>
|
package/src/style.css
CHANGED
|
@@ -126,6 +126,102 @@
|
|
|
126
126
|
opacity: 0.7;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
.dcs-form-file__status {
|
|
130
|
+
margin: -0.15rem 0 0;
|
|
131
|
+
font-size: 0.78rem;
|
|
132
|
+
font-weight: 700;
|
|
133
|
+
letter-spacing: 0.03em;
|
|
134
|
+
color: rgba(71, 85, 105, 0.82);
|
|
135
|
+
text-transform: uppercase;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.dcs-form-file-preview-grid {
|
|
139
|
+
display: grid;
|
|
140
|
+
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
|
141
|
+
gap: 0.85rem;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.dcs-form-file-preview {
|
|
145
|
+
position: relative;
|
|
146
|
+
overflow: hidden;
|
|
147
|
+
display: grid;
|
|
148
|
+
gap: 0.65rem;
|
|
149
|
+
padding: 0.65rem;
|
|
150
|
+
border: 1px solid rgba(15, 23, 42, 0.12);
|
|
151
|
+
border-radius: 1rem;
|
|
152
|
+
background:
|
|
153
|
+
linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(248, 250, 252, 0.9)),
|
|
154
|
+
radial-gradient(circle at top left, rgba(37, 99, 235, 0.1), transparent 40%);
|
|
155
|
+
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.dcs-form-file-preview__image,
|
|
159
|
+
.dcs-form-file-preview__placeholder {
|
|
160
|
+
width: 100%;
|
|
161
|
+
aspect-ratio: 4 / 3;
|
|
162
|
+
border-radius: 0.75rem;
|
|
163
|
+
background: rgba(15, 23, 42, 0.08);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.dcs-form-file-preview__image {
|
|
167
|
+
object-fit: cover;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.dcs-form-file-preview__placeholder {
|
|
171
|
+
display: grid;
|
|
172
|
+
place-items: center;
|
|
173
|
+
font-size: 0.8rem;
|
|
174
|
+
font-weight: 800;
|
|
175
|
+
letter-spacing: 0.08em;
|
|
176
|
+
color: rgba(71, 85, 105, 0.8);
|
|
177
|
+
text-transform: uppercase;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.dcs-form-file-preview__meta {
|
|
181
|
+
min-width: 0;
|
|
182
|
+
display: grid;
|
|
183
|
+
gap: 0.1rem;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.dcs-form-file-preview__name {
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
font-size: 0.85rem;
|
|
189
|
+
font-weight: 700;
|
|
190
|
+
line-height: 1.35;
|
|
191
|
+
text-overflow: ellipsis;
|
|
192
|
+
white-space: nowrap;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.dcs-form-file-preview__size {
|
|
196
|
+
font-size: 0.75rem;
|
|
197
|
+
color: rgba(71, 85, 105, 0.86);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.dcs-form-file-preview__remove {
|
|
201
|
+
appearance: none;
|
|
202
|
+
justify-self: start;
|
|
203
|
+
border: 1px solid rgba(185, 28, 28, 0.2);
|
|
204
|
+
border-radius: 9999px;
|
|
205
|
+
padding: 0.4rem 0.75rem;
|
|
206
|
+
font: inherit;
|
|
207
|
+
font-size: 0.75rem;
|
|
208
|
+
font-weight: 800;
|
|
209
|
+
line-height: 1;
|
|
210
|
+
color: #991b1b;
|
|
211
|
+
background: rgba(254, 242, 242, 0.95);
|
|
212
|
+
cursor: pointer;
|
|
213
|
+
transition:
|
|
214
|
+
transform 0.15s ease,
|
|
215
|
+
border-color 0.15s ease,
|
|
216
|
+
background-color 0.15s ease;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.dcs-form-file-preview__remove:hover {
|
|
220
|
+
transform: translateY(-1px);
|
|
221
|
+
border-color: rgba(185, 28, 28, 0.42);
|
|
222
|
+
background: #fff;
|
|
223
|
+
}
|
|
224
|
+
|
|
129
225
|
.dcs-form-select {
|
|
130
226
|
appearance: none;
|
|
131
227
|
}
|