@asd20/ui-next 2.30.0 → 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,12 @@
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
+
3
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)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.30.0",
3
+ "version": "2.30.1",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -5,26 +5,52 @@
5
5
  class="asd20-public-form-field__upload"
6
6
  >
7
7
  <label :for="inputId">{{ displayLabel }}</label>
8
- <input
8
+ <asd20-button
9
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"
10
21
  type="file"
11
22
  accept=".pdf,application/pdf"
12
23
  multiple
24
+ hidden
13
25
  :required="field.required"
14
- :aria-describedby="describedBy"
15
- :aria-invalid="hasErrors ? 'true' : undefined"
16
- @change="
17
- $emit('update:modelValue', Array.from($event.target.files || []))
18
- "
26
+ :aria-label="field.label"
27
+ @change="addFiles"
19
28
  />
20
- <ul v-if="modelValue.length">
21
- <li
22
- v-for="(file, index) in modelValue"
23
- :key="index"
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"
24
36
  >
25
- {{ file.name }}
26
- </li>
27
- </ul>
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>
28
54
  </div>
29
55
  <asd20-text-area-input
30
56
  v-else-if="field.type === 'textarea'"
@@ -65,30 +91,36 @@
65
91
  class="asd20-public-form-field__select"
66
92
  >
67
93
  <label :for="inputId">{{ displayLabel }}</label>
68
- <select
69
- :id="inputId"
70
- :value="modelValue"
71
- :required="field.required"
72
- :aria-describedby="describedBy"
73
- :aria-invalid="hasErrors ? 'true' : undefined"
74
- @change="$emit('update:modelValue', $event.target.value)"
75
- >
76
- <option value="">
77
- Please select
78
- </option>
79
- <option
80
- v-for="option in field.options"
81
- :key="option.value"
82
- :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)"
83
102
  >
84
- {{ option.label }}
85
- </option>
86
- </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>
87
115
  </div>
88
116
 
89
117
  <fieldset
90
118
  v-else-if="field.type === 'radio'"
91
119
  class="asd20-public-form-field__choices"
120
+ :class="{
121
+ 'asd20-public-form-field__choices--described':
122
+ field.options.some(option => option.description),
123
+ }"
92
124
  :aria-describedby="describedBy"
93
125
  :aria-invalid="hasErrors ? 'true' : undefined"
94
126
  >
@@ -108,7 +140,7 @@
108
140
  @change="$emit('update:modelValue', option.value)"
109
141
  />
110
142
  <span>
111
- {{ option.label }}
143
+ <strong class="asd20-public-form-field__option-title">{{ option.label }}</strong>
112
144
  <span
113
145
  v-if="option.description"
114
146
  class="asd20-public-form-field__option-description"
@@ -161,6 +193,7 @@
161
193
  </template>
162
194
 
163
195
  <script>
196
+ import Asd20Button from '../../atoms/Asd20Button'
164
197
  import Asd20Checkbox from '../../atoms/Asd20Checkbox'
165
198
  import Asd20TextAreaInput from '../Asd20TextAreaInput'
166
199
  import Asd20TextInput from '../Asd20TextInput'
@@ -180,7 +213,7 @@ function districtToday() {
180
213
 
181
214
  export default {
182
215
  name: 'Asd20PublicFormField',
183
- components: { Asd20Checkbox, Asd20TextAreaInput, Asd20TextInput },
216
+ components: { Asd20Button, Asd20Checkbox, Asd20TextAreaInput, Asd20TextInput },
184
217
  props: {
185
218
  field: { type: Object, required: true },
186
219
  modelValue: { type: [String, Boolean, Array], default: '' },
@@ -196,6 +229,9 @@ export default {
196
229
  }
197
230
  },
198
231
  computed: {
232
+ selectedFiles() {
233
+ return Array.isArray(this.modelValue) ? this.modelValue : []
234
+ },
199
235
  dateMaximum() {
200
236
  return this.field.type === 'date' && this.field.maxDate === 'today'
201
237
  ? districtToday()
@@ -239,6 +275,21 @@ export default {
239
275
  )
240
276
  },
241
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
+ },
242
293
  }
243
294
  </script>
244
295
 
@@ -246,6 +297,44 @@ export default {
246
297
  @use '../../../design/component-stack' as *;
247
298
 
248
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
+
249
338
  &__option-description {
250
339
  display: block;
251
340
  font-weight: normal;
@@ -272,7 +361,24 @@ export default {
272
361
  margin-bottom: space(0.125);
273
362
  }
274
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
+
275
380
  select {
381
+ appearance: none;
276
382
  background: white;
277
383
  border: 2px solid var(--color__accent);
278
384
  border-radius: var(--website-shape__radius-s);
@@ -280,6 +386,7 @@ export default {
280
386
  color: var(--website-card__reverse-background-color);
281
387
  font: inherit;
282
388
  padding: space(0.25);
389
+ padding-right: 3rem;
283
390
  width: 100%;
284
391
  }
285
392
 
@@ -296,6 +403,15 @@ export default {
296
403
  margin: space(0.125) 0;
297
404
  }
298
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
+
299
415
  &__help,
300
416
  &__character-count,
301
417
  &__errors {
@@ -143,6 +143,7 @@
143
143
  >
144
144
  <asd20-button
145
145
  :label="formDefinition.submitLabel"
146
+ :disabled="!canSubmit"
146
147
  horizontal
147
148
  centered
148
149
  bordered
@@ -152,6 +153,7 @@
152
153
  <asd20-button
153
154
  v-else
154
155
  :label="formDefinition.submitLabel"
156
+ :disabled="!canSubmit"
155
157
  horizontal
156
158
  centered
157
159
  bordered
@@ -266,6 +268,13 @@ export default {
266
268
  }
267
269
  },
268
270
  computed: {
271
+ canSubmit() {
272
+ return Boolean(
273
+ this.formDefinition &&
274
+ !this.sending &&
275
+ validatePublicFormValues(this.formDefinition, this.values).valid
276
+ )
277
+ },
269
278
  visibleFields() {
270
279
  return (this.formDefinition?.fields || [])
271
280
  .filter(field => isPublicFormFieldVisible(field, this.values))
@@ -107,6 +107,7 @@ function normalizeField(field) {
107
107
  ),
108
108
  options: undefined,
109
109
  visibleWhen: normalizeCondition(field.visibleWhen),
110
+ requiredUnlessUploaded: boundedText(field.requiredUnlessUploaded, 'Upload field name', 64),
110
111
  requiredWhen:
111
112
  field.requiredWhen == null
112
113
  ? undefined
@@ -191,13 +192,13 @@ export function isPublicFormFieldVisible(field, values) {
191
192
  }
192
193
 
193
194
  export function isPublicFormFieldRequired(field, values) {
194
- return (
195
- field.required ||
196
- field.requiredWhen?.some(condition =>
197
- condition.values.includes(String(values[condition.fieldName] || '').trim())
198
- ) ||
199
- false
195
+ const required = field.required || field.requiredWhen?.some(condition =>
196
+ condition.values.includes(String(values[condition.fieldName] || '').trim())
200
197
  )
198
+ const uploaded = field.requiredUnlessUploaded &&
199
+ Array.isArray(values[field.requiredUnlessUploaded]) &&
200
+ values[field.requiredUnlessUploaded].length > 0
201
+ return Boolean(required && !uploaded)
201
202
  }
202
203
 
203
204
  export async function serializePublicFormValues(definition, values) {
@@ -252,6 +253,12 @@ export function normalizePublicFormDefinition(definition) {
252
253
  throw new Error('The public form contains duplicate field names.')
253
254
 
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
+ }
255
262
  for (const condition of [
256
263
  field.visibleWhen,
257
264
  ...(field.requiredWhen || []),