@asd20/ui-next 2.25.1 → 2.25.3

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.25.3](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.25.2...ui-next-v2.25.3) (2026-09-25)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * add typo-tolerant serches and common aliases to search ([49f86a1](https://github.com/academydistrict20/asd20-ui-next/commit/49f86a104c8f33b3c2e53ebee696b95dbef43014))
9
+
10
+ ## [2.25.2](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.25.1...ui-next-v2.25.2) (2026-09-23)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * refine public form presentation ([0e8d038](https://github.com/academydistrict20/asd20-ui-next/commit/0e8d0388783f522d65adeb6fb03f5c6a19c15456))
16
+
3
17
  ## [2.25.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.25.0...ui-next-v2.25.1) (2026-09-23)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.25.1",
3
+ "version": "2.25.3",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  :required="field.required"
9
9
  :minlength="field.minLength"
10
10
  :maxlength="field.maxLength"
11
- :show-character-count="showCharacterCount"
11
+ :show-character-count="false"
12
12
  :show-pristine-validation-errors="showPristineValidationErrors"
13
13
  :autocomplete="field.autocomplete || undefined"
14
14
  :aria-describedby="describedBy"
@@ -26,7 +26,7 @@
26
26
  :minlength="field.minLength"
27
27
  :maxlength="field.maxLength"
28
28
  :max="dateMaximum"
29
- :show-character-count="showCharacterCount"
29
+ :show-character-count="false"
30
30
  :show-pristine-validation-errors="showPristineValidationErrors"
31
31
  :autocomplete="field.autocomplete || undefined"
32
32
  :aria-describedby="describedBy"
@@ -97,13 +97,22 @@
97
97
  @update:modelValue="$emit('update:modelValue', $event)"
98
98
  />
99
99
 
100
- <p
101
- v-if="field.helpText"
102
- :id="helpId"
103
- class="asd20-public-form-field__help"
100
+ <div
101
+ v-if="field.helpText || hasCharacterCount"
102
+ class="asd20-public-form-field__guidance"
104
103
  >
105
- {{ field.helpText }}
106
- </p>
104
+ <p
105
+ v-if="field.helpText"
106
+ :id="helpId"
107
+ class="asd20-public-form-field__help"
108
+ >
109
+ {{ field.helpText }}
110
+ </p>
111
+ <span
112
+ v-if="hasCharacterCount"
113
+ class="asd20-public-form-field__character-count"
114
+ >{{ characterCountText }}</span>
115
+ </div>
107
116
  <ul
108
117
  v-if="hasErrors"
109
118
  :id="errorId"
@@ -174,6 +183,19 @@ export default {
174
183
  hasErrors() {
175
184
  return this.errors.length > 0
176
185
  },
186
+ hasCharacterCount() {
187
+ return (
188
+ this.showCharacterCount &&
189
+ Number.isInteger(this.field.maxLength) &&
190
+ this.field.maxLength > 0 &&
191
+ typeof this.modelValue === 'string'
192
+ )
193
+ },
194
+ characterCountText() {
195
+ return this.hasCharacterCount
196
+ ? `${this.modelValue.length}/${this.field.maxLength}`
197
+ : ''
198
+ },
177
199
  describedBy() {
178
200
  return (
179
201
  [
@@ -226,15 +248,55 @@ export default {
226
248
  }
227
249
 
228
250
  &__help,
251
+ &__character-count,
229
252
  &__errors {
230
253
  color: var(--website-card__reverse-background-color);
231
254
  font-size: 0.75rem;
232
255
  margin: space(0.125) 0 0;
233
256
  }
234
257
 
258
+ &__guidance {
259
+ align-items: flex-end;
260
+ display: flex;
261
+ gap: space(0.25);
262
+ justify-content: space-between;
263
+ }
264
+
265
+ &__help {
266
+ flex: 1 1 auto;
267
+ }
268
+
269
+ &__character-count {
270
+ flex: 0 0 auto;
271
+ margin-left: auto;
272
+ white-space: nowrap;
273
+ }
274
+
235
275
  &__errors {
236
276
  color: asd20-swatch('emergency', -1);
237
277
  padding-left: space(1);
238
278
  }
279
+
280
+ & :deep(.asd20-checkbox.asd20-checkbox__checkbox) {
281
+ align-items: flex-start;
282
+ gap: space(0.25);
283
+
284
+ input {
285
+ flex: 0 0 auto;
286
+ margin: 0.2rem 0 0;
287
+ opacity: 1;
288
+ position: static;
289
+ }
290
+
291
+ label {
292
+ align-items: flex-start;
293
+ flex: 1 1 auto;
294
+ padding: 0;
295
+
296
+ span {
297
+ margin-right: 0;
298
+ }
299
+ }
300
+ }
239
301
  }
240
302
  </style>
@@ -34,7 +34,11 @@
34
34
  role="status"
35
35
  aria-live="polite"
36
36
  >
37
- {{ formDefinition.successMessage }}
37
+ <p>{{ formDefinition.successMessage }}</p>
38
+ <template v-if="formDefinition.successDetails">
39
+ <hr />
40
+ <p>{{ formDefinition.successDetails }}</p>
41
+ </template>
38
42
  </div>
39
43
 
40
44
  <form
@@ -236,7 +240,7 @@ export default {
236
240
  return this.formDefinition?.title || 'Public Form'
237
241
  },
238
242
  showPristineValidationErrors() {
239
- return this.formDefinition?.id !== 'accessibility-issue'
243
+ return false
240
244
  },
241
245
  errorEntries() {
242
246
  if (!this.formDefinition) return []
@@ -330,7 +334,7 @@ export default {
330
334
  return `${this.instanceId}-${name}`
331
335
  },
332
336
  shouldShowCharacterCount(field) {
333
- return this.formDefinition.id !== 'accessibility-issue' || field.name === 'issue'
337
+ return field.showCharacterCount === true
334
338
  },
335
339
  updateValue(name, value) {
336
340
  this.values[name] = value
@@ -459,8 +463,7 @@ export default {
459
463
  }
460
464
 
461
465
  &__error-summary,
462
- &__feedback,
463
- &__success {
466
+ &__feedback {
464
467
  font-weight: 600;
465
468
  margin: space(0.5) 0;
466
469
  }
@@ -476,7 +479,12 @@ export default {
476
479
 
477
480
  &__success {
478
481
  font-size: 1.1rem;
479
- text-align: center;
482
+ margin: space(0.5) 0;
483
+ text-align: left;
484
+
485
+ p {
486
+ margin: space(0.5) 0;
487
+ }
480
488
  }
481
489
 
482
490
  &__fallback {
@@ -2917,11 +2917,9 @@ export default {
2917
2917
  this.skipKeywordWatcher = true
2918
2918
  this.keywords = this.limitWords(text, 4)
2919
2919
  await this.searchPages({ keepAnswersTab: false, runId })
2920
- this.searchFilesWithGroupOwners({ runId })
2921
- .then(() =>
2922
- this.selectBestKeywordTabAfterContactResults({ runId, text })
2923
- )
2924
- .catch(() => {})
2920
+ const supportingSearchPromise = this.searchFilesWithGroupOwners({
2921
+ runId,
2922
+ }).catch(() => [])
2925
2923
  if (!this.isCurrentSearchRun(runId)) return
2926
2924
  this.currentTab = 'Pages'
2927
2925
  this.syncSearchState({ text, mode, tab: 'Pages' })
@@ -2929,6 +2927,10 @@ export default {
2929
2927
 
2930
2928
  // Log keyword-only search analytics
2931
2929
  const keywordAnalyticsPages = this.getAnalyticsPages()
2930
+ await supportingSearchPromise
2931
+ if (!this.isCurrentSearchRun(runId)) return
2932
+ this.selectBestKeywordTabAfterContactResults({ runId, text })
2933
+ const resultCounts = this.getKeywordAnalyticsResultCounts()
2932
2934
  const keywordLogPages = keywordAnalyticsPages
2933
2935
  .slice(0, 10)
2934
2936
  .map(p => ({ title: p.title || '', url: p.url || '' }))
@@ -2936,7 +2938,7 @@ export default {
2936
2938
  question: text,
2937
2939
  searchType: 'keyword_only',
2938
2940
  keywords: this.keywords || text,
2939
- hadResults: keywordAnalyticsPages.length > 0,
2941
+ hadResults: resultCounts.total > 0,
2940
2942
  relatedPages: keywordLogPages,
2941
2943
  organizationId:
2942
2944
  this.organization && this.organization.id
@@ -3572,6 +3574,21 @@ export default {
3572
3574
  )
3573
3575
  },
3574
3576
 
3577
+ getKeywordAnalyticsResultCounts() {
3578
+ const pages = this.getAnalyticsPages().length
3579
+ const files = Array.isArray(this.currentFiles)
3580
+ ? this.currentFiles.length
3581
+ : 0
3582
+ const contacts = Number(this.contactResultCount) || 0
3583
+
3584
+ return {
3585
+ pages,
3586
+ files,
3587
+ contacts,
3588
+ total: pages + files + contacts,
3589
+ }
3590
+ },
3591
+
3575
3592
  limitWords(text, maxWords = 4) {
3576
3593
  return (text || '')
3577
3594
  .trim()
@@ -40,6 +40,12 @@ function optionalMaxDate(value, type) {
40
40
  throw new Error('Maximum date is invalid.')
41
41
  }
42
42
 
43
+ function optionalBoolean(value, label) {
44
+ if (value == null) return false
45
+ if (typeof value !== 'boolean') throw new Error(`${label} is invalid.`)
46
+ return value
47
+ }
48
+
43
49
  function normalizeOptions(field) {
44
50
  if (!['select', 'radio'].includes(field.type)) return undefined
45
51
  if (
@@ -89,6 +95,10 @@ function normalizeField(field) {
89
95
  maxLength: optionalLength(field.maxLength, 'Maximum length'),
90
96
  maxDate: optionalMaxDate(field.maxDate, type),
91
97
  autocomplete: boundedText(field.autocomplete, 'Autocomplete value', 100),
98
+ showCharacterCount: optionalBoolean(
99
+ field.showCharacterCount,
100
+ 'Character-count visibility'
101
+ ),
92
102
  options: undefined,
93
103
  }
94
104
 
@@ -99,6 +109,13 @@ function normalizeField(field) {
99
109
  ) {
100
110
  throw new Error(`${normalized.label} has invalid length limits.`)
101
111
  }
112
+ if (
113
+ normalized.showCharacterCount &&
114
+ (!['text', 'email', 'tel', 'textarea'].includes(type) ||
115
+ normalized.maxLength == null)
116
+ ) {
117
+ throw new Error(`${normalized.label} has an invalid character count.`)
118
+ }
102
119
 
103
120
  normalized.options = normalizeOptions({
104
121
  ...field,
@@ -170,6 +187,11 @@ export function normalizePublicFormDefinition(definition) {
170
187
  1000,
171
188
  true
172
189
  ),
190
+ successDetails: boundedText(
191
+ definition.successDetails,
192
+ 'Success details',
193
+ 2000
194
+ ),
173
195
  fallbackContact: normalizedFallback,
174
196
  fields,
175
197
  }
@@ -2,6 +2,7 @@ import axios from 'axios'
2
2
 
3
3
  import { hasSearchProxyRuntime, resolveSearchProxyUrl } from './searchProxyUrl'
4
4
  import { normalizeBaseUrl, resolveRuntimeConfigValue } from './runtimeConfig'
5
+ import { buildTypoTolerantSearchQuery } from './searchQuery'
5
6
 
6
7
  /**
7
8
  * Creates a Azure Search payload
@@ -13,13 +14,7 @@ function searchPayload(state) {
13
14
  let query = '*'
14
15
 
15
16
  if (state.enableFuzzySearch) {
16
- // Search both by exact phrase and by fuzzy search of event name
17
- query = state.keywords.trim()
18
- ? `"${state.keywords.trim()}" OR name:${state.keywords
19
- .trim()
20
- .split(' ')
21
- .join('* OR name:')}*`
22
- : '*'
17
+ query = buildTypoTolerantSearchQuery(state.keywords, 'name')
23
18
  } else {
24
19
  query = state.keywords.trim()
25
20
  }
@@ -2,6 +2,7 @@ import axios from 'axios'
2
2
 
3
3
  import { hasSearchProxyRuntime, resolveSearchProxyUrl } from './searchProxyUrl'
4
4
  import { normalizeBaseUrl, resolveRuntimeConfigValue } from './runtimeConfig'
5
+ import { buildTypoTolerantSearchQuery } from './searchQuery'
5
6
 
6
7
  /**
7
8
  * Creates a Azure Search payload
@@ -43,13 +44,7 @@ function searchPayload(state) {
43
44
  }
44
45
 
45
46
  if (state.enableFuzzySearch) {
46
- // Search both by exact phrase and by fuzzy search of event name
47
- query = state.keywords.trim()
48
- ? `"${state.keywords.trim()}" OR title:${state.keywords
49
- .trim()
50
- .split(' ')
51
- .join('* OR title:')}*`
52
- : '*'
47
+ query = buildTypoTolerantSearchQuery(state.keywords, 'title')
53
48
  } else {
54
49
  query = state.keywords.trim()
55
50
  }
@@ -2,6 +2,7 @@ import axios from 'axios'
2
2
 
3
3
  import { hasSearchProxyRuntime, resolveSearchProxyUrl } from './searchProxyUrl'
4
4
  import { resolveRuntimeConfigValue } from './runtimeConfig'
5
+ import { buildTypoTolerantSearchQuery } from './searchQuery'
5
6
 
6
7
  export const NO_PAGE_RESULTS_FALLBACKS = [
7
8
  {
@@ -56,13 +57,7 @@ function searchPayload(state) {
56
57
  let query = '*'
57
58
 
58
59
  if (state.enableFuzzySearch) {
59
- // Search both by exact phrase and by fuzzy search of event name
60
- query = state.keywords.trim()
61
- ? `"${state.keywords.trim()}" OR title:${state.keywords
62
- .trim()
63
- .split(' ')
64
- .join('* OR title:')}*`
65
- : '*'
60
+ query = buildTypoTolerantSearchQuery(state.keywords, 'title')
66
61
  } else {
67
62
  query = state.keywords.trim()
68
63
  }
@@ -0,0 +1,70 @@
1
+ const MIN_FUZZY_TOKEN_LENGTH = 5
2
+
3
+ const SEARCH_ALIASES = Object.freeze({
4
+ childfind: ['child find'],
5
+ dresscode: ['dress code'],
6
+ foodmenu: ['food menu', 'lunch menu'],
7
+ hoco: ['homecoming'],
8
+ landsharks: ['land sharks'],
9
+ merch: ['merchandise', 'spirit wear'],
10
+ powderpuff: ['powder puff'],
11
+ printshop: ['print shop'],
12
+ studenthub: ['student hub'],
13
+ })
14
+
15
+ function escapePhrase(value) {
16
+ return value.replace(/"/g, '\\"')
17
+ }
18
+
19
+ function searchTokens(value) {
20
+ return Array.from(
21
+ new Set(
22
+ String(value || '')
23
+ .split(/\s+/)
24
+ .map(token => token.replace(/[^a-z0-9-]/gi, '').trim())
25
+ .filter(Boolean)
26
+ )
27
+ )
28
+ }
29
+
30
+ function prefixClause(tokens, field = '') {
31
+ if (!tokens.length) return ''
32
+ const prefix = field ? `${field}:` : ''
33
+ return tokens.map(token => `${prefix}${token}*`).join(' AND ')
34
+ }
35
+
36
+ function fuzzyClause(tokens) {
37
+ if (!tokens.some(token => token.length >= MIN_FUZZY_TOKEN_LENGTH)) return ''
38
+ return tokens
39
+ .map(token =>
40
+ token.length >= MIN_FUZZY_TOKEN_LENGTH ? `${token}~1` : token
41
+ )
42
+ .join(' AND ')
43
+ }
44
+
45
+ export function buildTypoTolerantSearchQuery(keywords, field = '') {
46
+ const trimmed = String(keywords || '').trim()
47
+ if (!trimmed) return '*'
48
+
49
+ const tokens = searchTokens(trimmed)
50
+ if (!tokens.length) return `"${escapePhrase(trimmed)}"`
51
+
52
+ const clauses = [
53
+ `"${escapePhrase(trimmed)}"`,
54
+ prefixClause(tokens, field),
55
+ prefixClause(tokens),
56
+ fuzzyClause(tokens),
57
+ ]
58
+ const aliases = SEARCH_ALIASES[trimmed.toLowerCase()] || []
59
+
60
+ aliases.forEach(alias => {
61
+ const aliasTokens = searchTokens(alias)
62
+ clauses.push(`"${escapePhrase(alias)}"`)
63
+ clauses.push(prefixClause(aliasTokens, field))
64
+ clauses.push(prefixClause(aliasTokens))
65
+ })
66
+
67
+ return Array.from(new Set(clauses.filter(Boolean)))
68
+ .map(clause => (clause.includes(' AND ') ? `(${clause})` : clause))
69
+ .join(' OR ')
70
+ }