@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.
@@ -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' && 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(', ')}`
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
- modelValue: File | undefined
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 accept = computed(() => (props.field.validation?.accept ?? []).join(','))
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
- emit('update:modelValue', target.files?.[0])
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
  }