@asd20/ui-next 2.29.1 → 2.30.1

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.30.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.30.0...ui-next-v2.30.1) (2026-10-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * update styling on student records form ([3c9e73b](https://github.com/academydistrict20/asd20-ui-next/commit/3c9e73b669569aead47346fe52c5df432d1ecbf7))
9
+
10
+ # [2.30.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.29.1...ui-next-v2.30.0) (2026-10-06)
11
+
12
+
13
+ ### Features
14
+
15
+ * refactor student records request workflow; reduce to a single form ([1dda060](https://github.com/academydistrict20/asd20-ui-next/commit/1dda06043264a5e9ca0dd7f5b012a51b4cae52ec))
16
+
3
17
  ## [2.29.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.29.0...ui-next-v2.29.1) (2026-10-05)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.29.1",
3
+ "version": "2.30.1",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -1,7 +1,59 @@
1
1
  <template>
2
2
  <div class="asd20-public-form-field">
3
+ <div
4
+ v-if="field.type === 'file'"
5
+ class="asd20-public-form-field__upload"
6
+ >
7
+ <label :for="inputId">{{ displayLabel }}</label>
8
+ <asd20-button
9
+ :id="inputId"
10
+ label="Choose files"
11
+ horizontal
12
+ bordered
13
+ :aria-label="`Choose files for ${field.label.toLowerCase()}`"
14
+ :aria-describedby="describedBy"
15
+ :aria-invalid="hasErrors ? 'true' : undefined"
16
+ @click="$refs.fileInput.click()"
17
+ />
18
+ <input
19
+ :id="`${inputId}-input`"
20
+ ref="fileInput"
21
+ type="file"
22
+ accept=".pdf,application/pdf"
23
+ multiple
24
+ hidden
25
+ :required="field.required"
26
+ :aria-label="field.label"
27
+ @change="addFiles"
28
+ />
29
+ <div
30
+ class="asd20-public-form-field__file-selection"
31
+ aria-live="polite"
32
+ >
33
+ <ul
34
+ v-if="selectedFiles.length"
35
+ class="asd20-public-form-field__file-list"
36
+ >
37
+ <li
38
+ v-for="(file, index) in selectedFiles"
39
+ :key="index"
40
+ >
41
+ <span>{{ file.name }}</span>
42
+ <asd20-button
43
+ label="Remove"
44
+ text-only
45
+ :aria-label="`Remove ${file.name}`"
46
+ @click="removeFile(index)"
47
+ />
48
+ </li>
49
+ </ul>
50
+ <p v-else>
51
+ No file chosen
52
+ </p>
53
+ </div>
54
+ </div>
3
55
  <asd20-text-area-input
4
- v-if="field.type === 'textarea'"
56
+ v-else-if="field.type === 'textarea'"
5
57
  :id="inputId"
6
58
  :model-value="modelValue"
7
59
  :label="displayLabel"
@@ -39,30 +91,36 @@
39
91
  class="asd20-public-form-field__select"
40
92
  >
41
93
  <label :for="inputId">{{ displayLabel }}</label>
42
- <select
43
- :id="inputId"
44
- :value="modelValue"
45
- :required="field.required"
46
- :aria-describedby="describedBy"
47
- :aria-invalid="hasErrors ? 'true' : undefined"
48
- @change="$emit('update:modelValue', $event.target.value)"
49
- >
50
- <option value="">
51
- Please select
52
- </option>
53
- <option
54
- v-for="option in field.options"
55
- :key="option.value"
56
- :value="option.value"
94
+ <div class="asd20-public-form-field__select-control">
95
+ <select
96
+ :id="inputId"
97
+ :value="modelValue"
98
+ :required="field.required"
99
+ :aria-describedby="describedBy"
100
+ :aria-invalid="hasErrors ? 'true' : undefined"
101
+ @change="$emit('update:modelValue', $event.target.value)"
57
102
  >
58
- {{ option.label }}
59
- </option>
60
- </select>
103
+ <option value="">
104
+ Please select
105
+ </option>
106
+ <option
107
+ v-for="option in field.options"
108
+ :key="option.value"
109
+ :value="option.value"
110
+ >
111
+ {{ option.label }}
112
+ </option>
113
+ </select>
114
+ </div>
61
115
  </div>
62
116
 
63
117
  <fieldset
64
118
  v-else-if="field.type === 'radio'"
65
119
  class="asd20-public-form-field__choices"
120
+ :class="{
121
+ 'asd20-public-form-field__choices--described':
122
+ field.options.some(option => option.description),
123
+ }"
66
124
  :aria-describedby="describedBy"
67
125
  :aria-invalid="hasErrors ? 'true' : undefined"
68
126
  >
@@ -81,7 +139,13 @@
81
139
  :required="field.required"
82
140
  @change="$emit('update:modelValue', option.value)"
83
141
  />
84
- <span>{{ option.label }}</span>
142
+ <span>
143
+ <strong class="asd20-public-form-field__option-title">{{ option.label }}</strong>
144
+ <span
145
+ v-if="option.description"
146
+ class="asd20-public-form-field__option-description"
147
+ >{{ option.description }}</span>
148
+ </span>
85
149
  </label>
86
150
  </fieldset>
87
151
 
@@ -129,6 +193,7 @@
129
193
  </template>
130
194
 
131
195
  <script>
196
+ import Asd20Button from '../../atoms/Asd20Button'
132
197
  import Asd20Checkbox from '../../atoms/Asd20Checkbox'
133
198
  import Asd20TextAreaInput from '../Asd20TextAreaInput'
134
199
  import Asd20TextInput from '../Asd20TextInput'
@@ -148,10 +213,10 @@ function districtToday() {
148
213
 
149
214
  export default {
150
215
  name: 'Asd20PublicFormField',
151
- components: { Asd20Checkbox, Asd20TextAreaInput, Asd20TextInput },
216
+ components: { Asd20Button, Asd20Checkbox, Asd20TextAreaInput, Asd20TextInput },
152
217
  props: {
153
218
  field: { type: Object, required: true },
154
- modelValue: { type: [String, Boolean], default: '' },
219
+ modelValue: { type: [String, Boolean, Array], default: '' },
155
220
  errors: { type: Array, default: () => [] },
156
221
  inputId: { type: String, required: true },
157
222
  showCharacterCount: { type: Boolean, default: true },
@@ -164,6 +229,9 @@ export default {
164
229
  }
165
230
  },
166
231
  computed: {
232
+ selectedFiles() {
233
+ return Array.isArray(this.modelValue) ? this.modelValue : []
234
+ },
167
235
  dateMaximum() {
168
236
  return this.field.type === 'date' && this.field.maxDate === 'today'
169
237
  ? districtToday()
@@ -207,6 +275,21 @@ export default {
207
275
  )
208
276
  },
209
277
  },
278
+ methods: {
279
+ addFiles(event) {
280
+ const addedFiles = Array.from(event.target.files || [])
281
+ if (addedFiles.length) {
282
+ this.$emit('update:modelValue', [...this.selectedFiles, ...addedFiles])
283
+ }
284
+ // The model owns the selection; reset the picker so later choices append
285
+ // and a removed document can be selected again.
286
+ event.target.value = ''
287
+ },
288
+ removeFile(index) {
289
+ this.$emit('update:modelValue', this.selectedFiles.filter((_, fileIndex) => fileIndex !== index))
290
+ this.$nextTick(() => document.getElementById(this.inputId)?.focus())
291
+ },
292
+ },
210
293
  }
211
294
  </script>
212
295
 
@@ -214,6 +297,49 @@ export default {
214
297
  @use '../../../design/component-stack' as *;
215
298
 
216
299
  .asd20-public-form-field {
300
+ &__option-title {
301
+ font-weight: 700;
302
+ }
303
+
304
+ &__upload {
305
+ align-items: flex-start;
306
+ display: flex;
307
+ flex-direction: column;
308
+ gap: 0.5rem;
309
+ }
310
+
311
+ &__file-selection {
312
+ width: 100%;
313
+
314
+ p {
315
+ margin: 0;
316
+ }
317
+ }
318
+
319
+ &__file-list {
320
+ display: flex;
321
+ flex-direction: column;
322
+ gap: 0.5rem;
323
+ list-style: none;
324
+ margin: 0;
325
+ padding: 0;
326
+
327
+ li {
328
+ align-items: baseline;
329
+ display: flex;
330
+ gap: 0.5rem;
331
+ }
332
+
333
+ span {
334
+ overflow-wrap: anywhere;
335
+ }
336
+ }
337
+
338
+ &__option-description {
339
+ display: block;
340
+ font-weight: normal;
341
+ margin-top: 0.25rem;
342
+ }
217
343
  // Public intake controls must stay legible on every school theme. Limit the
218
344
  // neutral overrides to this wrapper so other site components keep their colors.
219
345
  --color__accent: #3d3d3d;
@@ -235,7 +361,24 @@ export default {
235
361
  margin-bottom: space(0.125);
236
362
  }
237
363
 
364
+ &__select-control {
365
+ position: relative;
366
+
367
+ &::after {
368
+ border-left: 0.5rem solid transparent;
369
+ border-right: 0.5rem solid transparent;
370
+ border-top: 0.5rem solid currentColor;
371
+ content: '';
372
+ pointer-events: none;
373
+ position: absolute;
374
+ right: 1rem;
375
+ top: 50%;
376
+ transform: translateY(-50%);
377
+ }
378
+ }
379
+
238
380
  select {
381
+ appearance: none;
239
382
  background: white;
240
383
  border: 2px solid var(--color__accent);
241
384
  border-radius: var(--website-shape__radius-s);
@@ -243,6 +386,7 @@ export default {
243
386
  color: var(--website-card__reverse-background-color);
244
387
  font: inherit;
245
388
  padding: space(0.25);
389
+ padding-right: 3rem;
246
390
  width: 100%;
247
391
  }
248
392
 
@@ -259,6 +403,15 @@ export default {
259
403
  margin: space(0.125) 0;
260
404
  }
261
405
 
406
+ &__choices--described label {
407
+ align-items: flex-start;
408
+ }
409
+
410
+ &__choices input[type='radio'] {
411
+ flex: 0 0 auto;
412
+ margin: 0;
413
+ }
414
+
262
415
  &__help,
263
416
  &__character-count,
264
417
  &__errors {
@@ -54,13 +54,21 @@
54
54
  class="asd20-public-form-modal__introduction"
55
55
  >
56
56
  <template v-if="inlineIntroductionParts">
57
- {{ inlineIntroductionParts.before }}<a :href="formDefinition.introductionLink.href">{{ formDefinition.introductionLink.label }}</a>{{ inlineIntroductionParts.after }}
57
+ {{ inlineIntroductionParts.before
58
+ }}<a :href="formDefinition.introductionLink.href">{{
59
+ formDefinition.introductionLink.label
60
+ }}</a>{{ inlineIntroductionParts.after }}
58
61
  </template>
59
62
  <template v-else>
60
63
  {{ formDefinition.introduction }}
61
64
  </template>
62
65
  </p>
63
- <p v-if="formDefinition.introductionLink && !formDefinition.introductionLink.inline">
66
+ <p
67
+ v-if="
68
+ formDefinition.introductionLink &&
69
+ !formDefinition.introductionLink.inline
70
+ "
71
+ >
64
72
  <a :href="formDefinition.introductionLink.href">
65
73
  {{ formDefinition.introductionLink.label }}
66
74
  </a>
@@ -88,7 +96,7 @@
88
96
  </div>
89
97
 
90
98
  <asd20-public-form-field
91
- v-for="field in formDefinition.fields"
99
+ v-for="field in visibleFields"
92
100
  :key="field.name"
93
101
  :class="{
94
102
  'asd20-public-form-modal__requester-choice':
@@ -135,6 +143,7 @@
135
143
  >
136
144
  <asd20-button
137
145
  :label="formDefinition.submitLabel"
146
+ :disabled="!canSubmit"
138
147
  horizontal
139
148
  centered
140
149
  bordered
@@ -144,6 +153,7 @@
144
153
  <asd20-button
145
154
  v-else
146
155
  :label="formDefinition.submitLabel"
156
+ :disabled="!canSubmit"
147
157
  horizontal
148
158
  centered
149
159
  bordered
@@ -199,6 +209,9 @@ import Asd20PublicFormField from '../../molecules/Asd20PublicFormField'
199
209
  import Recaptcha from '../../utils/Recaptcha'
200
210
  import {
201
211
  createPublicFormValues,
212
+ isPublicFormFieldVisible,
213
+ isPublicFormFieldRequired,
214
+ serializePublicFormValues,
202
215
  normalizePublicFormDefinition,
203
216
  validatePublicFormValues,
204
217
  } from '../../../helpers/publicFormDefinition'
@@ -255,6 +268,21 @@ export default {
255
268
  }
256
269
  },
257
270
  computed: {
271
+ canSubmit() {
272
+ return Boolean(
273
+ this.formDefinition &&
274
+ !this.sending &&
275
+ validatePublicFormValues(this.formDefinition, this.values).valid
276
+ )
277
+ },
278
+ visibleFields() {
279
+ return (this.formDefinition?.fields || [])
280
+ .filter(field => isPublicFormFieldVisible(field, this.values))
281
+ .map(field => ({
282
+ ...field,
283
+ required: isPublicFormFieldRequired(field, this.values),
284
+ }))
285
+ },
258
286
  modalTitle() {
259
287
  return this.formDefinition?.title || 'Public Form'
260
288
  },
@@ -288,7 +316,9 @@ export default {
288
316
  },
289
317
  hasFallbackContact() {
290
318
  const fallback = this.formDefinition?.fallbackContact
291
- return Boolean(fallback?.instructions || fallback?.email || fallback?.phone)
319
+ return Boolean(
320
+ fallback?.instructions || fallback?.email || fallback?.phone
321
+ )
292
322
  },
293
323
  showFallbackContact() {
294
324
  return this.submissionFailed && this.hasFallbackContact
@@ -377,6 +407,12 @@ export default {
377
407
  },
378
408
  updateValue(name, value) {
379
409
  this.values[name] = value
410
+ for (const field of this.formDefinition.fields) {
411
+ if (!isPublicFormFieldVisible(field, this.values)) {
412
+ this.values[field.name] =
413
+ field.type === 'checkbox' ? false : field.type === 'file' ? [] : ''
414
+ }
415
+ }
380
416
  if (this.validationAttempted) this.validate(false)
381
417
  },
382
418
  validate(focusSummary = true) {
@@ -424,6 +460,12 @@ export default {
424
460
  if (status === 429) {
425
461
  return 'Too many submissions were received recently. Please wait and try again, or use the contact options listed.'
426
462
  }
463
+ if (
464
+ status === 502 &&
465
+ typeof serverMessage === 'string' &&
466
+ serverMessage.trim()
467
+ )
468
+ return serverMessage
427
469
  if (status === 502) {
428
470
  return 'The form could not be delivered right now. Please try again later or use the contact options listed.'
429
471
  }
@@ -440,7 +482,9 @@ export default {
440
482
  async submitForm(captchaToken = '') {
441
483
  if (this.sending || !this.validate()) return
442
484
  if (this.captchaEnabled && !captchaToken) {
443
- this.showFeedback('Verification is required before submitting this form.')
485
+ this.showFeedback(
486
+ 'Verification is required before submitting this form.'
487
+ )
444
488
  return
445
489
  }
446
490
 
@@ -449,7 +493,10 @@ export default {
449
493
  this.submissionFailed = false
450
494
  try {
451
495
  const payload = {
452
- ...this.values,
496
+ ...(await serializePublicFormValues(
497
+ this.formDefinition,
498
+ this.values
499
+ )),
453
500
  website: this.honeypot,
454
501
  captchaToken,
455
502
  }
@@ -7,6 +7,7 @@ export const SUPPORTED_PUBLIC_FORM_FIELD_TYPES = Object.freeze([
7
7
  'select',
8
8
  'radio',
9
9
  'checkbox',
10
+ 'file',
10
11
  ])
11
12
 
12
13
  const FIELD_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/
@@ -65,7 +66,12 @@ function normalizeOptions(field) {
65
66
  if (seen.has(value))
66
67
  throw new Error(`${field.label} has duplicate choices.`)
67
68
  seen.add(value)
68
- return { label, value }
69
+ const description = boundedText(
70
+ option?.description,
71
+ 'Option description',
72
+ 1000
73
+ )
74
+ return description ? { label, value, description } : { label, value }
69
75
  })
70
76
  }
71
77
 
@@ -100,6 +106,12 @@ function normalizeField(field) {
100
106
  'Character-count visibility'
101
107
  ),
102
108
  options: undefined,
109
+ visibleWhen: normalizeCondition(field.visibleWhen),
110
+ requiredUnlessUploaded: boundedText(field.requiredUnlessUploaded, 'Upload field name', 64),
111
+ requiredWhen:
112
+ field.requiredWhen == null
113
+ ? undefined
114
+ : normalizeConditions(field.requiredWhen),
103
115
  }
104
116
 
105
117
  if (
@@ -122,9 +134,103 @@ function normalizeField(field) {
122
134
  type,
123
135
  label: normalized.label,
124
136
  })
137
+ if (type === 'file') {
138
+ if (
139
+ field.accept !== 'application/pdf' ||
140
+ field.maxFiles !== 5 ||
141
+ field.maxFileBytes !== 5 * 1024 * 1024 ||
142
+ field.maxTotalFileBytes !== 10 * 1024 * 1024
143
+ ) {
144
+ throw new Error('The public form has invalid upload limits.')
145
+ }
146
+ Object.assign(normalized, {
147
+ accept: field.accept,
148
+ maxFiles: field.maxFiles,
149
+ maxFileBytes: field.maxFileBytes,
150
+ maxTotalFileBytes: field.maxTotalFileBytes,
151
+ })
152
+ }
125
153
  return normalized
126
154
  }
127
155
 
156
+ function normalizeCondition(condition) {
157
+ if (condition == null) return undefined
158
+ if (
159
+ !condition ||
160
+ typeof condition !== 'object' ||
161
+ Array.isArray(condition) ||
162
+ !FIELD_NAME_PATTERN.test(condition.fieldName) ||
163
+ !Array.isArray(condition.values) ||
164
+ !condition.values.length ||
165
+ condition.values.length > MAX_OPTIONS ||
166
+ condition.values.some(
167
+ value => typeof value !== 'string' || !OPTION_VALUE_PATTERN.test(value)
168
+ )
169
+ ) {
170
+ throw new Error('The public form has an invalid field condition.')
171
+ }
172
+ return { fieldName: condition.fieldName, values: [...condition.values] }
173
+ }
174
+
175
+ function normalizeConditions(conditions) {
176
+ if (
177
+ !Array.isArray(conditions) ||
178
+ !conditions.length ||
179
+ conditions.length > MAX_FIELDS ||
180
+ conditions.some(condition => condition == null)
181
+ ) {
182
+ throw new Error('The public form has invalid required conditions.')
183
+ }
184
+ return conditions.map(normalizeCondition)
185
+ }
186
+
187
+ export function isPublicFormFieldVisible(field, values) {
188
+ return (
189
+ !field.visibleWhen ||
190
+ field.visibleWhen.values.includes(String(values[field.visibleWhen.fieldName] || '').trim())
191
+ )
192
+ }
193
+
194
+ export function isPublicFormFieldRequired(field, values) {
195
+ const required = field.required || field.requiredWhen?.some(condition =>
196
+ condition.values.includes(String(values[condition.fieldName] || '').trim())
197
+ )
198
+ const uploaded = field.requiredUnlessUploaded &&
199
+ Array.isArray(values[field.requiredUnlessUploaded]) &&
200
+ values[field.requiredUnlessUploaded].length > 0
201
+ return Boolean(required && !uploaded)
202
+ }
203
+
204
+ export async function serializePublicFormValues(definition, values) {
205
+ const result = { ...values }
206
+ for (const field of definition.fields.filter(
207
+ field => field.type === 'file'
208
+ )) {
209
+ result[field.name] = await Promise.all(
210
+ (values[field.name] || []).map(
211
+ file =>
212
+ new Promise((resolve, reject) => {
213
+ const reader = new FileReader()
214
+ reader.onload = () =>
215
+ resolve({
216
+ name: file.name,
217
+ contentType: 'application/pdf',
218
+ contentBase64: String(reader.result).split(',')[1],
219
+ })
220
+ reader.onerror = () =>
221
+ reject(
222
+ new Error(
223
+ 'A supporting document could not be read. Select the document again.'
224
+ )
225
+ )
226
+ reader.readAsDataURL(file)
227
+ })
228
+ )
229
+ )
230
+ }
231
+ return result
232
+ }
233
+
128
234
  export function normalizePublicFormDefinition(definition) {
129
235
  if (
130
236
  !definition ||
@@ -146,6 +252,32 @@ export function normalizePublicFormDefinition(definition) {
146
252
  if (names.size !== fields.length)
147
253
  throw new Error('The public form contains duplicate field names.')
148
254
 
255
+ for (const field of fields) {
256
+ if (field.requiredUnlessUploaded) {
257
+ const upload = fields.find(candidate => candidate.name === field.requiredUnlessUploaded)
258
+ if (!upload || upload.type !== 'file' || upload === field) {
259
+ throw new Error('The public form has an invalid upload requirement reference.')
260
+ }
261
+ }
262
+ for (const condition of [
263
+ field.visibleWhen,
264
+ ...(field.requiredWhen || []),
265
+ ].filter(Boolean)) {
266
+ const dependency = fields.find(
267
+ candidate => candidate.name === condition.fieldName
268
+ )
269
+ if (
270
+ !dependency ||
271
+ dependency === field ||
272
+ !['radio', 'select'].includes(dependency.type) ||
273
+ condition.values.some(
274
+ value => !dependency.options.some(option => option.value === value)
275
+ )
276
+ ) {
277
+ throw new Error('The public form has an invalid condition reference.')
278
+ }
279
+ }
280
+ }
149
281
  const version = Number(definition.version)
150
282
  if (!Number.isInteger(version) || version < 1)
151
283
  throw new Error('The public form version is invalid.')
@@ -169,7 +301,8 @@ export function normalizePublicFormDefinition(definition) {
169
301
  }
170
302
 
171
303
  const id = boundedText(definition.id, 'Form ID', 100, true)
172
- if (!FORM_ID_PATTERN.test(id)) throw new Error('The public form ID is invalid.')
304
+ if (!FORM_ID_PATTERN.test(id))
305
+ throw new Error('The public form ID is invalid.')
173
306
 
174
307
  const introduction = boundedText(
175
308
  definition.introduction,
@@ -202,7 +335,9 @@ export function normalizePublicFormDefinition(definition) {
202
335
  (!introduction.includes(label) ||
203
336
  introduction.indexOf(label) !== introduction.lastIndexOf(label))
204
337
  ) {
205
- throw new Error('The inline introduction link label must appear once in the introduction.')
338
+ throw new Error(
339
+ 'The inline introduction link label must appear once in the introduction.'
340
+ )
206
341
  }
207
342
  introductionLink = inline ? { label, href, inline: true } : { label, href }
208
343
  }
@@ -234,7 +369,7 @@ export function createPublicFormValues(definition) {
234
369
  return Object.fromEntries(
235
370
  definition.fields.map(field => [
236
371
  field.name,
237
- field.type === 'checkbox' ? false : '',
372
+ field.type === 'file' ? [] : field.type === 'checkbox' ? false : '',
238
373
  ])
239
374
  )
240
375
  }
@@ -268,21 +403,47 @@ export function validatePublicFormValues(definition, sourceValues = {}) {
268
403
  const values = {}
269
404
 
270
405
  for (const field of definition.fields) {
406
+ if (!isPublicFormFieldVisible(field, sourceValues)) {
407
+ values[field.name] =
408
+ field.type === 'file' ? [] : field.type === 'checkbox' ? false : ''
409
+ continue
410
+ }
411
+ const required = isPublicFormFieldRequired(field, sourceValues)
271
412
  const rawValue = sourceValues[field.name]
272
413
  const fieldErrors = []
273
414
 
274
- if (field.type === 'checkbox') {
415
+ if (field.type === 'file') {
416
+ const files = rawValue || []
417
+ values[field.name] = files
418
+ if (
419
+ !Array.isArray(files) ||
420
+ files.length > field.maxFiles ||
421
+ files.some(
422
+ file =>
423
+ !(file instanceof File) ||
424
+ !/\.pdf$/i.test(file.name) ||
425
+ (file.type && file.type !== 'application/pdf') ||
426
+ file.size > field.maxFileBytes ||
427
+ file.size === 0
428
+ ) ||
429
+ files.reduce((size, file) => size + file.size, 0) >
430
+ field.maxTotalFileBytes
431
+ ) {
432
+ fieldErrors.push(
433
+ 'Upload up to 5 PDF documents, 5 MB each and 10 MB total.'
434
+ )
435
+ }
436
+ } else if (field.type === 'checkbox') {
275
437
  const value = rawValue === true
276
438
  values[field.name] = value
277
- if (field.required && !value)
278
- fieldErrors.push(`${field.label} is required.`)
439
+ if (required && !value) fieldErrors.push(`${field.label} is required.`)
279
440
  } else {
280
441
  const hasInvalidType = rawValue != null && typeof rawValue !== 'string'
281
442
  const value = typeof rawValue === 'string' ? rawValue.trim() : ''
282
443
  values[field.name] = field.type === 'email' ? value.toLowerCase() : value
283
444
 
284
445
  if (hasInvalidType) fieldErrors.push(`${field.label} must be text.`)
285
- else if (field.required && !value)
446
+ else if (required && !value)
286
447
  fieldErrors.push(`${field.label} is required.`)
287
448
  if (value && field.minLength != null && value.length < field.minLength) {
288
449
  fieldErrors.push(