@asd20/ui-next 2.32.1 → 2.33.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.33.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.32.2...ui-next-v2.33.0) (2026-10-20)
4
+
5
+
6
+ ### Features
7
+
8
+ * breadcrumbs now reflect previous wayfinding page if applicable; otherwise use current heirarchy ([d48b8ac](https://github.com/academydistrict20/asd20-ui-next/commit/d48b8ac0057c5a73b3f358f250025be822e78ef1))
9
+
10
+ ## [2.32.2](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.32.1...ui-next-v2.32.2) (2026-10-14)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * refine handbook template; fix interior handbook links; move ctas; move translation indicator ([89c8b63](https://github.com/academydistrict20/asd20-ui-next/commit/89c8b6308e81ac6a0005f0d0fc8dac0ec92ecf34))
16
+
3
17
  ## [2.32.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.32.0...ui-next-v2.32.1) (2026-10-12)
4
18
 
5
19
 
package/README.md CHANGED
@@ -35,6 +35,27 @@ multi-repo contract, not as an app-local component folder.
35
35
  - Unit test runner: Vitest
36
36
  - Component development: Storybook 8 with its retained webpack 5 builder
37
37
 
38
+ ## Breadcrumb Navigation
39
+
40
+ Website templates accept an optional `breadcrumbContext` page reference supplied
41
+ by the website's client navigation tracker. A visitor arriving from a wayfinding
42
+ page sees that section in the breadcrumb when the destination is one of its
43
+ message detail links or related pages. The context can continue between related
44
+ detail pages and is cleared when the visitor leaves that section.
45
+
46
+ Context is stored with the browser history entry, so refresh and Back/Forward
47
+ can restore the context of that visit. Native same-tab links can also carry a short-lived
48
+ arrival record when the referring page confirms the transition. Direct visits,
49
+ unknown origins, unavailable browser storage, and discarded history state fall
50
+ back to existing breadcrumbs. Context is restored after hydration.
51
+
52
+ Only the header links change. URLs, Related Pages, `parentPage`, dates,
53
+ categories, and the existing announcement/story navigation retain their behavior.
54
+ There is no per-page editor setting or additional breadcrumb data request.
55
+
56
+ Rollout requires releasing this UI package, updating the website's UI dependency,
57
+ and deploying the website integration. CommCenter and the database need no changes.
58
+
38
59
  ## Public Entry Points
39
60
 
40
61
  Supported import paths are defined in `package.json` and guarded by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.32.1",
3
+ "version": "2.33.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -1116,7 +1116,7 @@ export function buildRichBodyRenderModel(input = {}) {
1116
1116
  }
1117
1117
 
1118
1118
  // This prepass is deliberately independent of Vue instances and mounted topics.
1119
- export function buildPublicationHeadingModels({ bodies = [], reservedIds = [], documentUrl, linkResolverFactory } = {}) {
1119
+ export function buildPublicationHeadingModels({ bodies = [], reservedIds = [], reservedIdsFactory, documentUrl, linkResolverFactory } = {}) {
1120
1120
  const prefixes = new Set()
1121
1121
  const prepared = bodies.map(body => {
1122
1122
  const prefix = body.headingIdPrefix
@@ -1134,7 +1134,9 @@ export function buildPublicationHeadingModels({ bodies = [], reservedIds = [], d
1134
1134
  render(session)
1135
1135
  return { context, render, session }
1136
1136
  })
1137
- const headingPlan = buildHeadingIdPlan(prepared.map(row => row.session), reservedIds)
1137
+ const sourceIds = prepared.flatMap(row => row.session.entries.map(entry => entry.sourceId).filter(Boolean))
1138
+ const headingPlan = buildHeadingIdPlan(prepared.map(row => row.session),
1139
+ [...reservedIds, ...(reservedIdsFactory?.(sourceIds) || [])])
1138
1140
  const idPlan = linkResolverFactory
1139
1141
  ? Object.freeze({ ...headingPlan, linkResolver: linkResolverFactory(headingPlan) })
1140
1142
  : headingPlan
@@ -137,6 +137,7 @@
137
137
  class="asd20-template-handbook__index"
138
138
  aria-label="Handbook topics"
139
139
  >
140
+ <slot name="sidebar-status" />
140
141
  <button
141
142
  class="asd20-template-handbook__index-toggle"
142
143
  type="button"
@@ -234,15 +235,30 @@
234
235
  class="asd20-template-handbook__summary"
235
236
  v-html="topicSummary(entry.message)"
236
237
  />
238
+ <div
239
+ v-if="topicCallsToAction(entry.message).length"
240
+ class="asd20-template-handbook__calls-to-action"
241
+ >
242
+ <asd20-button
243
+ v-for="cta in topicCallsToAction(entry.message)"
244
+ :key="cta.label"
245
+ :label="cta.label"
246
+ :link="cta.url"
247
+ :data-asd20-handbook-link="cta.handbookTargetId || undefined"
248
+ bordered
249
+ transparent
250
+ />
251
+ </div>
237
252
  <hr
238
253
  v-if="topicSummary(entry.message)"
239
254
  class="asd20-template-handbook__summary-divider"
240
255
  />
241
256
  <asd20-messaging
242
- v-bind="topicMessage(entry.message)"
257
+ v-bind="entry.message"
243
258
  heading=""
244
259
  short-description=""
245
260
  detail-link=""
261
+ :calls-to-action="[]"
246
262
  :heading-level-offset="1"
247
263
  :heading-id-prefix="entry.key"
248
264
  />
@@ -373,11 +389,11 @@ export default {
373
389
  topicSummary(message) {
374
390
  return this.handbookDocument.linkResolver.html(message.shortDescription || message.summary || '')
375
391
  },
376
- topicMessage(message) {
377
- return { ...message, callsToAction: this.handbookDocument.linkResolver.callsToAction(message.callsToAction) }
392
+ topicCallsToAction(message) {
393
+ return this.handbookDocument.linkResolver.callsToAction(message.callsToAction)
378
394
  },
379
395
  entryForFragment(id) {
380
- return this.topicEntries.find(entry => entry.titleId === id ||
396
+ return this.topicEntries.find(entry => entry.titleId === id || entry.legacyTitleId === id ||
381
397
  Object.entries(this.publicationHeadings.idPlan.idsBySourceKey).some(([key, target]) => key.startsWith(entry.key + ':') && target === id))
382
398
  },
383
399
  onFragmentChange() {
@@ -388,6 +404,10 @@ export default {
388
404
  try { id = decodeURIComponent(String(fragment || '').replace(/^#/, '')) } catch { return false }
389
405
  const entry = this.entryForFragment(id)
390
406
  if (!entry) return false
407
+ if (id === entry.legacyTitleId) {
408
+ id = entry.titleId
409
+ window.history.replaceState(window.history.state, '', `#${encodeURIComponent(id)}`)
410
+ }
391
411
  const epoch = ++this.fragmentEpoch
392
412
  this.selectedMessageId = entry.key
393
413
  this.expandedTopicId = entry.key
@@ -529,7 +549,13 @@ export default {
529
549
  margin: 0 0 space(1);
530
550
  border: 0;
531
551
  border-top: 1px solid currentColor;
532
- opacity: 0.25;
552
+ }
553
+
554
+ &__calls-to-action {
555
+ display: flex;
556
+ flex-wrap: wrap;
557
+ gap: space(0.25);
558
+ margin-bottom: space(1);
533
559
  }
534
560
 
535
561
  &__index ol {
@@ -0,0 +1,42 @@
1
+ function localPath(value) {
2
+ if (typeof value !== 'string' || !value.startsWith('/') ||
3
+ value.startsWith('//') || /[\\\s]/.test(value)) return null
4
+ try {
5
+ return new URL(value, 'https://breadcrumb.local').pathname.replace(/\/+$/, '') || '/'
6
+ } catch { return null }
7
+ }
8
+
9
+ function isBreadcrumbContext(page, { id, path, organization }) {
10
+ const parentPath = localPath(page?.path?.path)
11
+ return Boolean(parentPath && typeof (page.name || page.title) === 'string' && (page.name || page.title) &&
12
+ (!id || page.id !== id) && (!path || parentPath !== localPath(path)) &&
13
+ (!page.status || page.status === 'Published') &&
14
+ (!organization?.id || !page.ownerOrganizationId ||
15
+ organization.id === page.ownerOrganizationId))
16
+ }
17
+
18
+ function textLabel(value) {
19
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
20
+ }
21
+
22
+ // Visit-specific context affects only the links displayed in the page header.
23
+ export function buildBreadcrumbLinks({
24
+ breadcrumbContext,
25
+ parentPage,
26
+ id,
27
+ path,
28
+ organization,
29
+ } = {}) {
30
+ const parent = isBreadcrumbContext(breadcrumbContext, { id, path, organization })
31
+ ? breadcrumbContext
32
+ : parentPage
33
+ if (!parent?.path?.path) return []
34
+
35
+ const links = parent.path.path === '/' ? [] : [{ url: '/', label: 'Home' }]
36
+ const label = parent.name || parent.title
37
+ links.push({
38
+ url: parent.path.path,
39
+ label: parent === breadcrumbContext ? textLabel(label) : label,
40
+ })
41
+ return links
42
+ }
@@ -4,6 +4,15 @@ import { createPublicationHeadingPrefix } from '../components/atoms/Asd20RichBod
4
4
  import { parseFragment } from 'parse5'
5
5
  import { createHandbookLinkResolver } from './handbookLinks'
6
6
 
7
+ function topicSlug(message) {
8
+ // CMS slugs survive translation; remove their publishing-date suffix.
9
+ const label = message.slug
10
+ ? String(message.slug).replace(/-\d{4}-\d{2}-\d{2}$/, '')
11
+ : message.heading || message.title || 'topic'
12
+ return String(label).normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
13
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'topic'
14
+ }
15
+
7
16
  export function documentImageUrls(html, baseUrl) {
8
17
  const urls = new Set()
9
18
  const walk = node => {
@@ -26,15 +35,33 @@ export function buildHandbookDocument({ messages = [], metaDescription = '', doc
26
35
  const ordinal = counts.get(identity) || 0
27
36
  counts.set(identity, ordinal + 1)
28
37
  const key = createPublicationHeadingPrefix(documentId, JSON.stringify([identity, ordinal]))
29
- return { message, key, titleId: `${key}-title`, subtopicsId: `${key}-subtopics` }
38
+ return { message, key, legacyTitleId: `${key}-title`, subtopicsId: `${key}-subtopics` }
30
39
  })
40
+ const fixedIds = ['main', 'handbook-content', 'handbook-topics', ...reservedIds,
41
+ ...entries.flatMap(entry => [entry.legacyTitleId, entry.subtopicsId])]
42
+ const slugs = entries.map(entry => topicSlug(entry.message))
43
+ const candidates = new Set(slugs)
31
44
  const coverKey = createPublicationHeadingPrefix(documentId, 'cover')
32
45
  const bodies = entries.map(entry => ({ bodyContent: entry.message.bodyContent, bodyJson: entry.message.bodyJson, headingLevelOffset: 1, headingIdPrefix: entry.key }))
33
46
  if (includeIntroductionBody) bodies.push({ bodyContent: messages[0]?.bodyContent, bodyJson: messages[0]?.bodyJson, headingIdPrefix: coverKey })
34
47
  const headingModels = buildPublicationHeadingModels({
35
48
  documentUrl,
36
49
  bodies,
37
- reservedIds: ['main', 'handbook-content', 'handbook-topics', ...reservedIds, ...entries.flatMap(entry => [entry.titleId, entry.subtopicsId])],
50
+ reservedIds: fixedIds,
51
+ reservedIdsFactory: sourceIds => {
52
+ // Keep authored section bookmarks intact when a title has the same slug.
53
+ const used = new Set([...fixedIds, ...sourceIds])
54
+ entries.forEach((entry, index) => {
55
+ const slug = slugs[index]
56
+ let id = slug, suffix = 2
57
+ while (used.has(id)) {
58
+ do { id = `${slug}-${suffix++}` } while (candidates.has(id))
59
+ }
60
+ used.add(id)
61
+ entry.titleId = id
62
+ })
63
+ return entries.map(entry => entry.titleId)
64
+ },
38
65
  linkResolverFactory: idPlan => createHandbookLinkResolver(entries, documentUrl, idPlan),
39
66
  })
40
67
  const introduction = buildPublicationIntroduction(messages[0], metaDescription)
@@ -20,11 +20,11 @@ function canonicalLinks(message) {
20
20
  const detail = links.find(link => link?.type === 'Detail Link')
21
21
  // Match the website mapper's single selected Detail Link. The same field is
22
22
  // retained in the PDF source, keeping online/PDF behavior identical.
23
- return [message.detailLink || detail?.url].filter(Boolean)
23
+ return [message.detailLink || detail?.url, ...(Array.isArray(message.contentPageUrls) ? message.contentPageUrls : [])].filter(Boolean)
24
24
  }
25
25
 
26
- // Only an included topic's declared detail-page URL identifies its canonical
27
- // destination. Duplicate/unknown destinations remain ordinary links.
26
+ // Included topics are identified by declared detail links and CMS page/message
27
+ // relationships. Duplicate/unknown destinations remain ordinary links.
28
28
  export function createHandbookLinkResolver(entries, documentUrl, idPlan) {
29
29
  const destinations = new Map()
30
30
  const handbookPage = destination(documentUrl, documentUrl)?.key
@@ -40,7 +40,16 @@ export function createHandbookLinkResolver(entries, documentUrl, idPlan) {
40
40
  }
41
41
  }
42
42
  function resolve(value) {
43
+ // Existing bookmarked topic fragments remain valid after adopting slugs.
44
+ let legacyFragment = ''
45
+ if (typeof value === 'string' && value.trim().startsWith('#')) legacyFragment = value.trim().slice(1)
43
46
  const url = destination(value, documentUrl)
47
+ if (url?.key === handbookPage) legacyFragment = url.hash.slice(1)
48
+ if (legacyFragment) {
49
+ try { legacyFragment = decodeURIComponent(legacyFragment) } catch { return null }
50
+ const entry = entries.find(entry => entry.legacyTitleId === legacyFragment)
51
+ if (entry) return { href: `#${encodeURIComponent(entry.titleId)}`, targetId: entry.titleId }
52
+ }
44
53
  const topics = url && destinations.get(url.key)
45
54
  if (topics?.size !== 1) return null
46
55
  const entry = [...topics][0]
@@ -214,6 +214,7 @@ export default function mapPageQueryResultToPageTemplateProps(
214
214
  pageTypeId: pageData.pageTypeId,
215
215
  factoids: pageData.factoids,
216
216
  parentPage: pageData.parentPage,
217
+ breadcrumbContext: pageData.breadcrumbContext || null,
217
218
  relatedPages: pageData.relatedPages
218
219
  ? pageData.relatedPages.map(p => {
219
220
  const page = _get(p, 'page[0]')
@@ -272,6 +273,14 @@ export default function mapPageQueryResultToPageTemplateProps(
272
273
  callsToAction,
273
274
  detailLink: detailLink ? detailLink.link.url : '',
274
275
  detailLinkLabel: detailLink ? detailLink.link.title : '',
276
+ // Page paths are relative to the current website. A message can be
277
+ // shared across organizations, so only retain this site's pages.
278
+ contentPageUrls: (m.message.pages || [])
279
+ .map(row => row.page)
280
+ .filter(page => page?.status === 'Published' &&
281
+ page.ownerOrganizationId === pageData.ownerOrganizationId)
282
+ .map(page => page.path?.path)
283
+ .filter(path => typeof path === 'string' && path.startsWith('/') && !path.startsWith('//')),
275
284
  }
276
285
  }),
277
286
  people: pageData.people,
@@ -1,4 +1,5 @@
1
1
  import responsiveBreakpointMixin from './responsiveBreakpointMixin'
2
+ import { buildBreadcrumbLinks } from '../helpers/breadcrumbLinks'
2
3
 
3
4
  function getMessageBodyHtml(message) {
4
5
  return (message && (message.bodyContent || message.bodyHtml)) || ''
@@ -81,6 +82,7 @@ export default {
81
82
  navigation: { type: Array, default: () => [] },
82
83
  actionItems: { type: Array, default: () => [] },
83
84
  parentPage: { type: Object, default: null },
85
+ breadcrumbContext: { type: Object, default: null },
84
86
  quickLinks: { type: Array, default: () => [] },
85
87
  socialLinks: { type: Array, default: () => [] },
86
88
  disclosureLinks: { type: Array, default: () => [] },
@@ -434,18 +436,13 @@ export default {
434
436
  },
435
437
 
436
438
  breadcrumbLinks() {
437
- let links =
438
- this.parentPage && this.parentPage.path.path !== '/'
439
- ? [{ url: '/', label: 'Home' }]
440
- : []
441
- if (this.parentPage) {
442
- links.push({
443
- url: this.parentPage.path.path,
444
- label: this.parentPage.name || this.parentPage.title,
445
- })
446
- }
447
-
448
- return links
439
+ return buildBreadcrumbLinks({
440
+ breadcrumbContext: this.breadcrumbContext,
441
+ parentPage: this.parentPage,
442
+ id: this.id,
443
+ path: this.path,
444
+ organization: this.organization,
445
+ })
449
446
  },
450
447
 
451
448
  publishDateTime() {