@asd20/ui-next 2.28.1 → 2.29.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.29.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.29.0...ui-next-v2.29.1) (2026-10-05)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * carry ai search verified supporting pages into related pages and pages tab ([46253e0](https://github.com/academydistrict20/asd20-ui-next/commit/46253e05ef064c99dbe3151cd093f6c938e2e9f6))
9
+
10
+ # [2.29.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.28.1...ui-next-v2.29.0) (2026-10-02)
11
+
12
+
13
+ ### Features
14
+
15
+ * **handbook:** add expandable H2 subtopics to index ([ac65b77](https://github.com/academydistrict20/asd20-ui-next/commit/ac65b778538ea118855cb876d712909ae038a679))
16
+
3
17
  ## [2.28.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.28.0...ui-next-v2.28.1) (2026-10-02)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.28.1",
3
+ "version": "2.29.1",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -2701,6 +2701,12 @@ export default {
2701
2701
  question: this.searchQuestion || this.inputText,
2702
2702
  }
2703
2703
  if (options.purpose) searchInput.purpose = options.purpose
2704
+ if (
2705
+ options.purpose === 'ai-related-pages' &&
2706
+ options.sourcePages?.length
2707
+ ) {
2708
+ searchInput.sourcePages = options.sourcePages
2709
+ }
2704
2710
 
2705
2711
  this.searchingPages = true
2706
2712
  try {
@@ -3105,6 +3111,16 @@ export default {
3105
3111
  ? `[Keywords: ${keywordsToUse.replace(/<[^>]*>/g, '')}]`
3106
3112
  : ''
3107
3113
 
3114
+ const sourcePages = Array.isArray(sources)
3115
+ ? sources
3116
+ .filter(
3117
+ source =>
3118
+ typeof source?.url === 'string' &&
3119
+ typeof source?.chunkId === 'string'
3120
+ )
3121
+ .slice(0, 8)
3122
+ .map(({ url, chunkId }) => ({ url, chunkId }))
3123
+ : []
3108
3124
  const assistantTurnKeywords = safeKeywords || keywordsToUse || ''
3109
3125
  const assistantTurn = {
3110
3126
  id: `a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -3115,7 +3131,8 @@ export default {
3115
3131
  feedbackValue: null,
3116
3132
  feedbackSubmitting: false,
3117
3133
  relatedPages: [],
3118
- relatedPagesStatus: keywordsToUse ? 'pending' : 'complete',
3134
+ relatedPagesStatus:
3135
+ keywordsToUse || sourcePages.length ? 'pending' : 'complete',
3119
3136
  }
3120
3137
  assistantTurnId = assistantTurn.id
3121
3138
  this.aiTranscript = [...this.aiTranscript, assistantTurn]
@@ -3124,13 +3141,14 @@ export default {
3124
3141
  this.scrollTurnToTop(this.anchoredUserTurnId, { defer: true })
3125
3142
  }
3126
3143
 
3127
- if (keywordsToUse) {
3144
+ if (keywordsToUse || sourcePages.length) {
3128
3145
  this.keywordsFromAi = true
3129
3146
  this.skipKeywordWatcher = true
3130
3147
  this.keywords = keywordsToUse
3131
3148
  await this.searchPages({
3132
3149
  keepAnswersTab: true,
3133
3150
  purpose: 'ai-related-pages',
3151
+ sourcePages,
3134
3152
  runId,
3135
3153
  })
3136
3154
  if (!this.isCurrentSearchRun(runId)) return
@@ -78,13 +78,50 @@
78
78
  v-for="message in topicMessages"
79
79
  :key="message.id"
80
80
  >
81
- <button
82
- type="button"
83
- :aria-current="activeMessage && activeMessage.id === message.id ? 'true' : null"
84
- @click="selectTopic(message.id)"
81
+ <div class="asd20-template-handbook__topic-row">
82
+ <button
83
+ class="asd20-template-handbook__topic-button"
84
+ type="button"
85
+ :aria-current="activeMessage && activeMessage.id === message.id ? 'true' : null"
86
+ @click="selectTopic(message.id)"
87
+ >
88
+ {{ message.heading || message.title }}
89
+ </button>
90
+ <button
91
+ v-if="subtopicsFor(message.id).length"
92
+ class="asd20-template-handbook__expand-button"
93
+ type="button"
94
+ :aria-expanded="expandedTopicId === message.id"
95
+ :aria-controls="`handbook-subtopics-${message.id}`"
96
+ :aria-label="`${expandedTopicId === message.id ? 'Hide' : 'Show'} sections in ${message.heading || message.title}`"
97
+ @click="toggleTopic(message.id)"
98
+ >
99
+ <asd20-icon
100
+ name="chevron"
101
+ size="sm"
102
+ focusable="false"
103
+ />
104
+ </button>
105
+ </div>
106
+ <ol
107
+ v-if="subtopicsFor(message.id).length"
108
+ v-show="expandedTopicId === message.id"
109
+ :id="`handbook-subtopics-${message.id}`"
110
+ class="asd20-template-handbook__subtopics"
85
111
  >
86
- {{ message.heading || message.title }}
87
- </button>
112
+ <li
113
+ v-for="subtopic in subtopicsFor(message.id)"
114
+ :key="subtopic.index"
115
+ >
116
+ <button
117
+ class="asd20-template-handbook__subtopic-button"
118
+ type="button"
119
+ @click="selectSubtopic(message.id, subtopic.index)"
120
+ >
121
+ {{ subtopic.label }}
122
+ </button>
123
+ </li>
124
+ </ol>
88
125
  </li>
89
126
  </ol>
90
127
  </nav>
@@ -92,6 +129,7 @@
92
129
  <article
93
130
  v-for="message in topicMessages"
94
131
  :key="message.id"
132
+ :data-message-id="message.id"
95
133
  class="asd20-template-handbook__topic"
96
134
  :class="{ 'is-active': activeMessage && activeMessage.id === message.id }"
97
135
  >
@@ -133,6 +171,7 @@
133
171
  <script>
134
172
  import Asd20SkipTo from '../../../components/atoms/Asd20SkipTo'
135
173
  import Asd20Messaging from '../../../components/atoms/Asd20Messaging'
174
+ import Asd20Icon from '../../../components/atoms/Asd20Icon'
136
175
  import Asd20PageHeader from '../../../components/organisms/Asd20PageHeader'
137
176
  import Asd20SiteNavigation from '../../../components/organisms/Asd20SiteNavigation'
138
177
  import Asd20NotificationGroup from '../../../components/organisms/Asd20NotificationGroup'
@@ -145,6 +184,7 @@ export default {
145
184
  components: {
146
185
  Asd20SkipTo,
147
186
  Asd20Messaging,
187
+ Asd20Icon,
148
188
  Asd20PageHeader,
149
189
  Asd20SiteNavigation,
150
190
  Asd20NotificationGroup,
@@ -154,6 +194,8 @@ export default {
154
194
  mixins: [pageTemplateMixin],
155
195
  data: () => ({
156
196
  selectedMessageId: null,
197
+ expandedTopicId: null,
198
+ subtopicsByMessageId: {},
157
199
  topicsOpen: false,
158
200
  }),
159
201
  computed: {
@@ -169,13 +211,61 @@ export default {
169
211
  || null
170
212
  },
171
213
  },
214
+ watch: {
215
+ messages: {
216
+ handler() {
217
+ this.$nextTick(() => this.refreshSubtopics())
218
+ },
219
+ deep: true,
220
+ },
221
+ },
222
+ mounted() {
223
+ this.$nextTick(() => this.refreshSubtopics())
224
+ },
172
225
  methods: {
173
226
  topicSummary(message) {
174
227
  return message.shortDescription || message.summary || ''
175
228
  },
176
- selectTopic(messageId) {
229
+ topicArticle(messageId) {
230
+ return Array.from(this.$el.querySelectorAll('.asd20-template-handbook__topic'))
231
+ .find(article => article.dataset.messageId === String(messageId))
232
+ },
233
+ subtopicsFor(messageId) {
234
+ return this.subtopicsByMessageId[messageId] || []
235
+ },
236
+ refreshSubtopics() {
237
+ const nextSubtopics = {}
238
+ this.topicMessages.forEach(message => {
239
+ const article = this.topicArticle(message.id)
240
+ if (!article) return
241
+ const headings = Array.from(article.querySelectorAll('.asd20-rich-body-content h2'))
242
+ const subtopics = headings
243
+ .map((heading, index) => ({ index, label: heading.textContent.trim() }))
244
+ .filter(subtopic => subtopic.label)
245
+ if (subtopics.length) nextSubtopics[message.id] = subtopics
246
+ })
247
+ if (JSON.stringify(nextSubtopics) !== JSON.stringify(this.subtopicsByMessageId)) {
248
+ this.subtopicsByMessageId = nextSubtopics
249
+ }
250
+ },
251
+ toggleTopic(messageId) {
252
+ this.expandedTopicId = this.expandedTopicId === messageId ? null : messageId
253
+ },
254
+ async selectTopic(messageId) {
177
255
  this.selectedMessageId = messageId
256
+ this.expandedTopicId = this.subtopicsFor(messageId).length ? messageId : null
178
257
  this.topicsOpen = false
258
+ await this.$nextTick()
259
+ this.topicArticle(messageId)?.scrollIntoView({ block: 'start' })
260
+ },
261
+ async selectSubtopic(messageId, headingIndex) {
262
+ this.selectedMessageId = messageId
263
+ this.expandedTopicId = messageId
264
+ this.topicsOpen = false
265
+ await this.$nextTick()
266
+ const article = this.topicArticle(messageId)
267
+ const headings = article?.querySelectorAll('.asd20-rich-body-content h2')
268
+ headings?.[headingIndex]?.scrollIntoView({ block: 'start' })
179
269
  },
180
270
  },
181
271
  }
@@ -227,8 +317,49 @@ export default {
227
317
  padding: 0;
228
318
  }
229
319
 
230
- &__index ol.is-open {
320
+ &__index ol.is-open,
321
+ &__index ol.is-open ol.asd20-template-handbook__subtopics {
322
+ display: block;
323
+ }
324
+
325
+ &__subtopics {
326
+ padding-left: space(0.75) !important;
327
+ }
328
+
329
+ &__topic-row {
330
+ display: flex;
331
+ align-items: stretch;
332
+ }
333
+
334
+ &__topic-button {
335
+ flex: 1 1 auto;
336
+ min-width: 0;
337
+ }
338
+
339
+ &__expand-button {
340
+ display: grid;
341
+ place-items: center;
342
+ flex: 0 0 44px;
343
+ min-height: 44px;
344
+ padding: 0;
345
+ border: 0;
346
+ background: transparent;
347
+ color: inherit;
348
+ }
349
+
350
+ &__expand-button .asd20-icon {
231
351
  display: block;
352
+ color: inherit;
353
+ transform: rotate(90deg);
354
+ transition: transform 180ms ease-in-out;
355
+ }
356
+
357
+ &__expand-button[aria-expanded='true'] .asd20-icon {
358
+ transform: rotate(180deg);
359
+ }
360
+
361
+ &__subtopic-button {
362
+ font-size: 0.9em !important;
232
363
  }
233
364
 
234
365
  &__index-heading {
@@ -241,7 +372,8 @@ export default {
241
372
  cursor: pointer;
242
373
  }
243
374
 
244
- &__index li button {
375
+ &__topic-button,
376
+ &__subtopic-button {
245
377
  display: block;
246
378
  width: 100%;
247
379
  min-height: 44px;
@@ -276,6 +408,11 @@ export default {
276
408
 
277
409
  &__topic {
278
410
  min-width: 0;
411
+ scroll-margin-top: space(2);
412
+ }
413
+
414
+ &__topic .asd20-rich-body-content h2 {
415
+ scroll-margin-top: space(2);
279
416
  }
280
417
 
281
418
  &__topic:not(.is-active) {
@@ -287,8 +424,8 @@ export default {
287
424
  .asd20-template-handbook {
288
425
  &__layout {
289
426
  grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr);
290
- gap: space(3);
291
- padding: space(3);
427
+ gap: space(2);
428
+ padding: space(2);
292
429
  }
293
430
 
294
431
  &__index {
@@ -314,6 +451,17 @@ export default {
314
451
  .asd20-template-handbook {
315
452
  margin-left: space(3);
316
453
  margin-top: 0;
454
+ &__layout {
455
+ grid-template-columns: minmax(12rem, 22rem) minmax(0, 1fr);
456
+ gap: space(3);
457
+ padding: space(3);
458
+ }
459
+ }
460
+ }
461
+
462
+ @media (prefers-reduced-motion: reduce) {
463
+ .asd20-template-handbook__expand-button .asd20-icon {
464
+ transition: none;
317
465
  }
318
466
  }
319
467
 
@@ -3,6 +3,7 @@ import axios from 'axios'
3
3
  import { hasSearchProxyRuntime, resolveSearchProxyUrl } from './searchProxyUrl'
4
4
  import { resolveRuntimeConfigValue } from './runtimeConfig'
5
5
  import { buildTypoTolerantSearchQuery } from './searchQuery'
6
+ import { mergeSupportingPages } from './supportingPages'
6
7
 
7
8
  export const NO_PAGE_RESULTS_FALLBACKS = [
8
9
  {
@@ -100,6 +101,8 @@ function searchPayload(state) {
100
101
  */
101
102
  export default async function queryPages({
102
103
  keywords = '',
104
+ purpose = '',
105
+ sourcePages = [],
103
106
  top = 25,
104
107
  requireKeywords = false,
105
108
  organizations = [],
@@ -111,7 +114,11 @@ export default async function queryPages({
111
114
  apiVersion = '',
112
115
  searchVersion = '',
113
116
  }) {
114
- if (!keywords && requireKeywords)
117
+ const supportingSources =
118
+ purpose === 'ai-related-pages' && Array.isArray(sourcePages)
119
+ ? sourcePages.slice(0, 8)
120
+ : []
121
+ if (!keywords && requireKeywords && !supportingSources.length)
115
122
  return {
116
123
  pages: [],
117
124
  facets: [],
@@ -150,16 +157,17 @@ export default async function queryPages({
150
157
  const resolvedRequestUrl =
151
158
  typeof requestUrl === 'string' && requestUrl.trim()
152
159
  ? requestUrl.trim()
153
- : !resolvedSearchEndpoint &&
154
- hasSearchProxyRuntime(runtimeConfig)
155
- ? resolveSearchProxyUrl('pages', runtimeConfig)
156
- : ''
160
+ : !resolvedSearchEndpoint && hasSearchProxyRuntime(runtimeConfig)
161
+ ? resolveSearchProxyUrl('pages', runtimeConfig)
162
+ : ''
157
163
 
158
164
  if (
159
165
  !resolvedRequestUrl &&
160
166
  (!resolvedSearchEndpoint || !resolvedSearchKey || !resolvedApiVersion)
161
167
  ) {
162
- throw new Error('Azure Search or proxy configuration is required for queryPages.')
168
+ throw new Error(
169
+ 'Azure Search or proxy configuration is required for queryPages.'
170
+ )
163
171
  }
164
172
 
165
173
  const { data } = resolvedRequestUrl
@@ -170,6 +178,9 @@ export default async function queryPages({
170
178
  keywords,
171
179
  organizations,
172
180
  top,
181
+ ...(supportingSources.length
182
+ ? { sourcePages: supportingSources }
183
+ : {}),
173
184
  },
174
185
  })
175
186
  : await axios({
@@ -194,10 +205,15 @@ export default async function queryPages({
194
205
  ),
195
206
  })
196
207
 
197
- const pages = Array.isArray(data.value) ? data.value : []
208
+ const pages = mergeSupportingPages(
209
+ Array.isArray(data.value) ? data.value : [],
210
+ Array.isArray(data.supportingPages) ? data.supportingPages : [],
211
+ supportingSources,
212
+ organizations
213
+ )
198
214
  const resolvedPages = pages.length
199
215
  ? pages
200
- : NO_PAGE_RESULTS_FALLBACKS.map((page) => ({
216
+ : NO_PAGE_RESULTS_FALLBACKS.map(page => ({
201
217
  ...page,
202
218
  categories: Array.isArray(page.categories) ? [...page.categories] : [],
203
219
  }))
@@ -0,0 +1,53 @@
1
+ export function normalizeSupportingPageUrl(value) {
2
+ if (typeof value !== 'string' || value.length > 2048) return ''
3
+ try {
4
+ const url = new URL(value.trim())
5
+ if (
6
+ !['http:', 'https:'].includes(url.protocol) ||
7
+ url.username ||
8
+ url.password
9
+ )
10
+ return ''
11
+ url.hash = ''
12
+ url.pathname = url.pathname.replace(/\/+$/, '') || '/'
13
+ return url.toString().replace(/\/$/, '')
14
+ } catch (_error) {
15
+ return ''
16
+ }
17
+ }
18
+
19
+ export function mergeSupportingPages(
20
+ pages = [],
21
+ supportingPages = [],
22
+ sourcePages = [],
23
+ organizations = []
24
+ ) {
25
+ const sourceUrls = sourcePages
26
+ .map(source => normalizeSupportingPageUrl(source?.url))
27
+ .filter(Boolean)
28
+ const supported = supportingPages
29
+ .filter(
30
+ page =>
31
+ page.type === 'page' &&
32
+ sourceUrls.includes(normalizeSupportingPageUrl(page.url)) &&
33
+ (!organizations.length ||
34
+ organizations.includes(page.ownerOrganizationId))
35
+ )
36
+ .sort(
37
+ (a, b) =>
38
+ sourceUrls.indexOf(normalizeSupportingPageUrl(a.url)) -
39
+ sourceUrls.indexOf(normalizeSupportingPageUrl(b.url))
40
+ )
41
+ if (!supported.length) return pages
42
+ const seen = new Set()
43
+ return [
44
+ ...supported.map(page => ({ ...page, isAnswerSupportingPage: true })),
45
+ ...pages,
46
+ ].filter(page => {
47
+ const key = normalizeSupportingPageUrl(page.url) || page.id
48
+ if (!key) return true
49
+ if (seen.has(key)) return false
50
+ seen.add(key)
51
+ return true
52
+ })
53
+ }