@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 +14 -0
- package/README.md +21 -0
- package/package.json +1 -1
- package/src/components/atoms/Asd20RichBodyContent/messageDocumentRenderer.js +4 -2
- package/src/components/templates/Asd20HandbookTemplate/index.vue +31 -5
- package/src/helpers/breadcrumbLinks.js +42 -0
- package/src/helpers/handbookDocument.js +29 -2
- package/src/helpers/handbookLinks.js +12 -3
- package/src/helpers/mapPageQueryResultToPageTemplateProps.js +9 -0
- package/src/mixins/pageTemplateMixin.js +9 -12
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
|
@@ -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
|
|
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="
|
|
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
|
-
|
|
377
|
-
return
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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,
|
|
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:
|
|
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
|
-
//
|
|
27
|
-
//
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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() {
|