@asd20/ui-next 2.23.5 → 2.24.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ # [2.24.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.23.6...ui-next-v2.24.0) (2026-09-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * add generic public form components ([12e7f88](https://github.com/academydistrict20/asd20-ui-next/commit/12e7f8812980744c46786b9effefe4cc15b5420a))
9
+
10
+ ## [2.23.6](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.23.5...ui-next-v2.23.6) (2026-09-16)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * restore/harden accessibility issue form ([d7ef90c](https://github.com/academydistrict20/asd20-ui-next/commit/d7ef90c76f1675e53528374cf226d05d38af1929))
16
+
3
17
  ## [2.23.5](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.23.4...ui-next-v2.23.5) (2026-09-11)
4
18
 
5
19
 
package/components.js CHANGED
@@ -50,6 +50,7 @@ export { default as Asd20Modal } from './src/components/molecules/Asd20Modal/ind
50
50
  export { default as Asd20Notification } from './src/components/molecules/Asd20Notification/index.vue'
51
51
  export { default as Asd20Picker } from './src/components/molecules/Asd20Picker/index.vue'
52
52
  export { default as Asd20Progress } from './src/components/molecules/Asd20Progress/index.vue'
53
+ export { default as Asd20PublicFormField } from './src/components/molecules/Asd20PublicFormField/index.vue'
53
54
  export { default as Asd20SearchField } from './src/components/molecules/Asd20SearchField/index.vue'
54
55
  export { default as Asd20Share } from './src/components/molecules/Asd20Share/index.vue'
55
56
  export { default as Asd20SliderInput } from './src/components/molecules/Asd20SliderInput/index.vue'
@@ -93,6 +94,7 @@ export { default as Asd20PageHeader } from './src/components/organisms/Asd20Page
93
94
  export { default as Asd20PeopleList } from './src/components/organisms/Asd20PeopleList/index.vue'
94
95
  export { default as Asd20PersonModal } from './src/components/organisms/Asd20PersonModal/index.vue'
95
96
  export { default as Asd20PrimaryHeader } from './src/components/organisms/Asd20PrimaryHeader/index.vue'
97
+ export { default as Asd20PublicFormModal } from './src/components/organisms/Asd20PublicFormModal/index.vue'
96
98
  export { default as Asd20QuicklinksMenu } from './src/components/organisms/Asd20QuicklinksMenu/index.vue'
97
99
  export { default as Asd20SchoolHomepageHeader } from './src/components/organisms/Asd20SchoolHomepageHeader/index.vue'
98
100
  export { default as Asd20SchoolHomepageVideoHeader } from './src/components/organisms/Asd20SchoolHomepageVideoHeader/index.vue'
package/helpers.js CHANGED
@@ -36,6 +36,13 @@ export { default as organizationItemsByCategory } from './src/helpers/organizati
36
36
  export { default as organizationSubtitle } from './src/helpers/organizationSubtitle.js'
37
37
  export { default as organizationTitle } from './src/helpers/organizationTitle.js'
38
38
  export { default as preventWidows } from './src/helpers/prevent-widows.js'
39
+ export {
40
+ fetchPublicFormDefinition,
41
+ normalizeFormId,
42
+ resolvePublicFormEndpoints,
43
+ resolvePublicFormsEndpoint,
44
+ submitPublicForm,
45
+ } from './src/helpers/publicForms.js'
39
46
  export { default as queryAiSite } from './src/helpers/queryAiSite.js'
40
47
  export { default as queryDepartments } from './src/helpers/queryDepartments.js'
41
48
  export { default as queryEvents } from './src/helpers/queryEvents.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.23.5",
3
+ "version": "2.24.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -0,0 +1,215 @@
1
+ <template>
2
+ <div class="asd20-public-form-field">
3
+ <asd20-text-area-input
4
+ v-if="field.type === 'textarea'"
5
+ :id="inputId"
6
+ :model-value="modelValue"
7
+ :label="displayLabel"
8
+ :required="field.required"
9
+ :minlength="field.minLength"
10
+ :maxlength="field.maxLength"
11
+ :autocomplete="field.autocomplete || undefined"
12
+ :aria-describedby="describedBy"
13
+ :aria-invalid="hasErrors ? 'true' : undefined"
14
+ @update:modelValue="$emit('update:modelValue', $event)"
15
+ />
16
+
17
+ <asd20-text-input
18
+ v-else-if="textInputTypes.includes(field.type)"
19
+ :id="inputId"
20
+ :model-value="modelValue"
21
+ :label="displayLabel"
22
+ :type="field.type"
23
+ :required="field.required"
24
+ :minlength="field.minLength"
25
+ :maxlength="field.maxLength"
26
+ :autocomplete="field.autocomplete || undefined"
27
+ :aria-describedby="describedBy"
28
+ :aria-invalid="hasErrors ? 'true' : undefined"
29
+ @update:modelValue="$emit('update:modelValue', $event)"
30
+ />
31
+
32
+ <div
33
+ v-else-if="field.type === 'select'"
34
+ class="asd20-public-form-field__select"
35
+ >
36
+ <label :for="inputId">{{ displayLabel }}</label>
37
+ <select
38
+ :id="inputId"
39
+ :value="modelValue"
40
+ :required="field.required"
41
+ :aria-describedby="describedBy"
42
+ :aria-invalid="hasErrors ? 'true' : undefined"
43
+ @change="$emit('update:modelValue', $event.target.value)"
44
+ >
45
+ <option value="">
46
+ Please select
47
+ </option>
48
+ <option
49
+ v-for="option in field.options"
50
+ :key="option.value"
51
+ :value="option.value"
52
+ >
53
+ {{ option.label }}
54
+ </option>
55
+ </select>
56
+ </div>
57
+
58
+ <fieldset
59
+ v-else-if="field.type === 'radio'"
60
+ class="asd20-public-form-field__choices"
61
+ :aria-describedby="describedBy"
62
+ :aria-invalid="hasErrors ? 'true' : undefined"
63
+ >
64
+ <legend>{{ displayLabel }}</legend>
65
+ <label
66
+ v-for="option in field.options"
67
+ :key="option.value"
68
+ :for="`${inputId}-${option.value}`"
69
+ >
70
+ <input
71
+ :id="`${inputId}-${option.value}`"
72
+ :name="inputId"
73
+ type="radio"
74
+ :value="option.value"
75
+ :checked="modelValue === option.value"
76
+ :required="field.required"
77
+ @change="$emit('update:modelValue', option.value)"
78
+ />
79
+ <span>{{ option.label }}</span>
80
+ </label>
81
+ </fieldset>
82
+
83
+ <asd20-checkbox
84
+ v-else-if="field.type === 'checkbox'"
85
+ :id="inputId"
86
+ :model-value="modelValue === true"
87
+ :label="displayLabel"
88
+ checkbox
89
+ :required="field.required || undefined"
90
+ :aria-describedby="describedBy"
91
+ :aria-invalid="hasErrors ? 'true' : undefined"
92
+ @update:modelValue="$emit('update:modelValue', $event)"
93
+ />
94
+
95
+ <p
96
+ v-if="field.helpText"
97
+ :id="helpId"
98
+ class="asd20-public-form-field__help"
99
+ >
100
+ {{ field.helpText }}
101
+ </p>
102
+ <ul
103
+ v-if="hasErrors"
104
+ :id="errorId"
105
+ class="asd20-public-form-field__errors"
106
+ >
107
+ <li
108
+ v-for="error in errors"
109
+ :key="error"
110
+ >
111
+ {{ error }}
112
+ </li>
113
+ </ul>
114
+ </div>
115
+ </template>
116
+
117
+ <script>
118
+ import Asd20Checkbox from '../../atoms/Asd20Checkbox'
119
+ import Asd20TextAreaInput from '../Asd20TextAreaInput'
120
+ import Asd20TextInput from '../Asd20TextInput'
121
+
122
+ export default {
123
+ name: 'Asd20PublicFormField',
124
+ components: { Asd20Checkbox, Asd20TextAreaInput, Asd20TextInput },
125
+ props: {
126
+ field: { type: Object, required: true },
127
+ modelValue: { type: [String, Boolean], default: '' },
128
+ errors: { type: Array, default: () => [] },
129
+ inputId: { type: String, required: true },
130
+ },
131
+ emits: ['update:modelValue'],
132
+ data() {
133
+ return {
134
+ textInputTypes: ['text', 'email', 'tel', 'date'],
135
+ }
136
+ },
137
+ computed: {
138
+ displayLabel() {
139
+ return this.field.required
140
+ ? `${this.field.label} (required)`
141
+ : this.field.label
142
+ },
143
+ helpId() {
144
+ return `${this.inputId}-help`
145
+ },
146
+ errorId() {
147
+ return `${this.inputId}-error`
148
+ },
149
+ hasErrors() {
150
+ return this.errors.length > 0
151
+ },
152
+ describedBy() {
153
+ return (
154
+ [
155
+ this.field.helpText ? this.helpId : '',
156
+ this.hasErrors ? this.errorId : '',
157
+ ]
158
+ .filter(Boolean)
159
+ .join(' ') || undefined
160
+ )
161
+ },
162
+ },
163
+ }
164
+ </script>
165
+
166
+ <style lang="scss" scoped>
167
+ @use '../../../design/component-stack' as *;
168
+
169
+ .asd20-public-form-field {
170
+ margin-bottom: space(0.5);
171
+
172
+ &__select label,
173
+ legend {
174
+ color: var(--website-card__reverse-background-color);
175
+ display: block;
176
+ margin-bottom: space(0.125);
177
+ }
178
+
179
+ select {
180
+ background: white;
181
+ border: 2px solid var(--color__accent);
182
+ border-radius: var(--website-shape__radius-s);
183
+ box-sizing: border-box;
184
+ color: var(--website-card__reverse-background-color);
185
+ font: inherit;
186
+ padding: space(0.25);
187
+ width: 100%;
188
+ }
189
+
190
+ fieldset {
191
+ border: 0;
192
+ margin: 0;
193
+ padding: 0;
194
+ }
195
+
196
+ &__choices label {
197
+ align-items: center;
198
+ display: flex;
199
+ gap: space(0.25);
200
+ margin: space(0.125) 0;
201
+ }
202
+
203
+ &__help,
204
+ &__errors {
205
+ color: var(--website-card__reverse-background-color);
206
+ font-size: 0.75rem;
207
+ margin: space(0.125) 0 0;
208
+ }
209
+
210
+ &__errors {
211
+ color: asd20-swatch('emergency', -1);
212
+ padding-left: space(1);
213
+ }
214
+ }
215
+ </style>
@@ -11,6 +11,8 @@
11
11
  <div
12
12
  v-if="submissionSuccess"
13
13
  class="submission-success"
14
+ role="status"
15
+ aria-live="polite"
14
16
  >
15
17
  <p style="text-align: center; font-weight: bold; font-size: 1.1rem;">
16
18
  Thank you! Your issue has been submitted.
@@ -60,6 +62,7 @@
60
62
  id="issue"
61
63
  v-model="form.issue"
62
64
  label="Describe the Issue"
65
+ maxlength="2000"
63
66
  help-text="<ul style='margin: 0 0 0.5rem 1rem; font-size: 0.75rem !important;'>
64
67
  <li>Specify the exact URL, program, service, or digital asset you had a problem with.</li>
65
68
  <li>Provide as much detail as possible, including the nature of the accessibility barrier, the date and time it occurred, any assistive technology you were using, or the nature of the program or service being denied.</li>
@@ -69,18 +72,55 @@
69
72
  :validator="validateIssue"
70
73
  @validated="validationErrors.issue = $event"
71
74
  />
72
- <asd20-button
73
- label="Submit Issue"
74
- :disabled="!isValid || sending"
75
- horizontal
76
- centered
77
- bordered
78
- @click="sendAccessibilityIssue"
79
- />
80
- <asd20-spinner
81
- v-if="sending"
82
- size="sm"
83
- />
75
+ <div
76
+ class="asd20-accessibility-issue-modal__honeypot"
77
+ aria-hidden="true"
78
+ >
79
+ <label for="accessibility-website">Website</label>
80
+ <input
81
+ id="accessibility-website"
82
+ v-model="form.website"
83
+ type="text"
84
+ name="website"
85
+ tabindex="-1"
86
+ autocomplete="off"
87
+ >
88
+ </div>
89
+ <div
90
+ class="asd20-accessibility-issue-modal__submit"
91
+ aria-live="polite"
92
+ >
93
+ <Recaptcha
94
+ v-if="!sending"
95
+ sitekey="6LfidKoUAAAAAFqr3QEbia3jIkecsZyxBYlMvWrX"
96
+ :load-recaptcha-script="true"
97
+ size="invisible"
98
+ @verify="captchaVerified"
99
+ @error="captchaFailed"
100
+ >
101
+ <asd20-button
102
+ label="Submit Issue"
103
+ :disabled="!isValid || sending"
104
+ horizontal
105
+ centered
106
+ bordered
107
+ @click="validateBeforeSend"
108
+ />
109
+ </Recaptcha>
110
+ <asd20-spinner
111
+ v-else
112
+ size="sm"
113
+ />
114
+ </div>
115
+ <div
116
+ v-if="feedbackMessage"
117
+ ref="feedback"
118
+ class="asd20-accessibility-issue-modal__feedback"
119
+ :role="feedbackRole"
120
+ tabindex="-1"
121
+ >
122
+ {{ feedbackMessage }}
123
+ </div>
84
124
  </template>
85
125
  </Asd20Viewport>
86
126
  </asd20-modal>
@@ -93,8 +133,10 @@ import Asd20TextAreaInput from '../../molecules/Asd20TextAreaInput'
93
133
  import Asd20Viewport from '../../atoms/Asd20Viewport'
94
134
  import Asd20Spinner from '../../atoms/Asd20Spinner'
95
135
  import Asd20Button from '../../atoms/Asd20Button'
96
-
97
- // import sendAccessibilityIssue from '../../../helpers/sendAccessibilityIssue.js'
136
+ import Recaptcha from '../../utils/Recaptcha'
137
+ import sendAccessibilityIssue, {
138
+ resolveAccessibilityIssueEndpoint,
139
+ } from '../../../helpers/sendAccessibilityIssue.js'
98
140
 
99
141
  export default {
100
142
  name: 'Asd20AccessibilityIssueModal',
@@ -105,6 +147,7 @@ export default {
105
147
  Asd20Viewport,
106
148
  Asd20Spinner,
107
149
  Asd20Button,
150
+ Recaptcha,
108
151
  },
109
152
  props: {
110
153
  open: { type: Boolean, default: false },
@@ -113,6 +156,8 @@ export default {
113
156
  return {
114
157
  sending: false,
115
158
  submissionSuccess: false,
159
+ feedbackMessage: '',
160
+ feedbackRole: 'alert',
116
161
  form: {
117
162
  firstName: '',
118
163
  lastName: '',
@@ -121,6 +166,7 @@ export default {
121
166
  address: '',
122
167
  organization: '',
123
168
  issue: '',
169
+ website: '',
124
170
  },
125
171
  validationErrors: {
126
172
  firstName: [],
@@ -135,13 +181,20 @@ export default {
135
181
  return Object.values(this.validationErrors).every(e => e.length === 0)
136
182
  },
137
183
  },
184
+ watch: {
185
+ open(value) {
186
+ if (value) {
187
+ this.submissionSuccess = false
188
+ this.feedbackMessage = ''
189
+ }
190
+ },
191
+ },
138
192
  methods: {
139
193
  validateEmailAddress({ value, validationErrors }) {
140
194
  const pattern = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/
141
195
  if (!pattern.test(value)) {
142
196
  validationErrors.push('A valid email address is required')
143
197
  }
144
- console.log('Email validation errors:', validationErrors)
145
198
  return validationErrors
146
199
  },
147
200
  validateIssue({ value, validationErrors }) {
@@ -155,25 +208,116 @@ export default {
155
208
  }
156
209
  return validationErrors
157
210
  },
158
- async sendAccessibilityIssue() {
159
- if (!this.isValid) return
211
+ resolveRuntimeConfig() {
212
+ return (
213
+ this.$config ||
214
+ this.$?.appContext?.config?.globalProperties?.$config ||
215
+ {}
216
+ )
217
+ },
218
+ getFormValidationMessages() {
219
+ const messages = []
220
+ const emailPattern =
221
+ /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/
222
+
223
+ if (!String(this.form.firstName || '').trim())
224
+ messages.push('First name is required.')
225
+ if (!String(this.form.lastName || '').trim())
226
+ messages.push('Last name is required.')
227
+ if (!emailPattern.test(String(this.form.email || '').trim()))
228
+ messages.push('Enter a valid email address.')
229
+
230
+ const issue = String(this.form.issue || '').trim()
231
+ if (!issue) messages.push('Describe the accessibility issue.')
232
+ else if (issue.length > 2000)
233
+ messages.push('The issue description must be 2,000 characters or fewer.')
234
+
235
+ return messages
236
+ },
237
+ validateBeforeSend(event) {
238
+ const messages = this.getFormValidationMessages()
239
+ if (!messages.length) return
240
+
241
+ event?.preventDefault?.()
242
+ this.showFeedback(messages.join(' '))
243
+ },
244
+ captchaVerified(token) {
245
+ if (token) this.sendAccessibilityIssue(token)
246
+ },
247
+ captchaFailed() {
248
+ this.showFeedback(
249
+ 'The verification service could not start. Please try again or use the email and phone options on this page.'
250
+ )
251
+ },
252
+ showFeedback(message, role = 'alert') {
253
+ this.feedbackMessage = message
254
+ this.feedbackRole = role
255
+ this.$nextTick(() => this.$refs.feedback?.focus?.())
256
+ },
257
+ getServerFailureMessage(error) {
258
+ const status = Number(error?.response?.status || 0)
259
+ const serverMessage = error?.response?.data?.error
260
+
261
+ if (status === 400 && serverMessage) return serverMessage
262
+ if (status === 403)
263
+ return 'The verification was not accepted. Please try again.'
264
+ if (status === 429)
265
+ return 'Too many reports were submitted recently. Please wait and try again, or use the email and phone options on this page.'
266
+
267
+ return 'Something went wrong while submitting the form. Please try again later or use the email and phone options on this page.'
268
+ },
269
+ async sendAccessibilityIssue(captchaToken = '') {
270
+ const validationMessages = this.getFormValidationMessages()
271
+ if (validationMessages.length) {
272
+ this.showFeedback(validationMessages.join(' '))
273
+ return
274
+ }
275
+
276
+ const endpoint = resolveAccessibilityIssueEndpoint({
277
+ runtimeConfig: this.resolveRuntimeConfig(),
278
+ })
279
+ const submissionClient =
280
+ this.$sendAccessibilityIssue ||
281
+ (endpoint
282
+ ? async issue => await sendAccessibilityIssue(issue, endpoint)
283
+ : null)
284
+
285
+ if (!submissionClient) {
286
+ console.error('Accessibility issue endpoint is not configured.')
287
+ this.showFeedback(
288
+ 'Issue reporting is not configured for this environment. Please use the email and phone options on this page.'
289
+ )
290
+ return
291
+ }
292
+
160
293
  this.sending = true
294
+ this.feedbackMessage = ''
161
295
 
162
296
  try {
163
- // console.log('Sending accessibility issue:', this.form)
164
- await this.$sendAccessibilityIssue(this.form)
297
+ await submissionClient({
298
+ ...this.form,
299
+ captchaToken,
300
+ })
165
301
  this.submissionSuccess = true
166
302
 
167
303
  // Wait 2 seconds, then close the modal
168
304
  setTimeout(() => {
169
305
  this.submissionSuccess = false
306
+ this.form = {
307
+ firstName: '',
308
+ lastName: '',
309
+ email: '',
310
+ phone: '',
311
+ address: '',
312
+ organization: '',
313
+ issue: '',
314
+ website: '',
315
+ }
170
316
  this.$emit('dismiss')
171
317
  }, 2000)
172
318
  } catch (e) {
173
319
  console.error('Accessibility issue submission failed:', e.message || e)
174
- alert(
175
- 'Something went wrong while submitting the form. Please try again later.'
176
- )
320
+ this.showFeedback(this.getServerFailureMessage(e))
177
321
  } finally {
178
322
  this.sending = false
179
323
  }
@@ -193,6 +337,27 @@ export default {
193
337
  font-family: Arial, sans-serif;
194
338
  font-size: 1rem;
195
339
  }
340
+
341
+ &__submit {
342
+ display: flex;
343
+ justify-content: center;
344
+ min-height: 3rem;
345
+ }
346
+
347
+ &__feedback {
348
+ font-weight: 600;
349
+ margin: space(0.5) 0;
350
+ text-align: center;
351
+ }
352
+
353
+ &__honeypot {
354
+ position: absolute;
355
+ width: 1px;
356
+ height: 1px;
357
+ margin: -1px;
358
+ overflow: hidden;
359
+ clip: rect(0, 0, 0, 0);
360
+ }
196
361
  }
197
362
 
198
363
  @media (min-width: 1024px) {