@asd20/ui-next 2.30.1 → 2.31.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 +4 -0
- package/components.js +2 -0
- package/helpers.js +8 -0
- package/package.json +2 -1
- package/src/components/atoms/Asd20Messaging/index.vue +6 -1
- package/src/components/atoms/Asd20RichBodyContent/headingContext.js +255 -0
- package/src/components/atoms/Asd20RichBodyContent/index.vue +31 -0
- package/src/components/atoms/Asd20RichBodyContent/messageDocumentRenderer.js +199 -45
- package/src/components/molecules/Asd20Accordion/index.vue +38 -14
- package/src/components/molecules/Asd20BodyAccordion/index.vue +23 -2
- package/src/components/molecules/Asd20PrintContents/index.vue +42 -0
- package/src/components/molecules/Asd20PrintHeader/index.vue +54 -0
- package/src/components/molecules/Asd20TableauEmbed/index.vue +1 -0
- package/src/components/templates/Asd20HandbookDocument/index.vue +85 -0
- package/src/components/templates/Asd20HandbookTemplate/index.vue +121 -69
- package/src/design/_document-print.scss +104 -0
- package/src/design/_print.scss +2 -0
- package/src/helpers/documentPrint.js +184 -0
- package/src/helpers/handbookDocument.js +44 -0
- package/src/helpers/publicationIntroduction.js +53 -0
- package/templates.js +1 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { nextTick } from 'vue'
|
|
2
|
+
|
|
3
|
+
const blocks = new WeakMap()
|
|
4
|
+
const sessions = new WeakMap()
|
|
5
|
+
|
|
6
|
+
// Component registration is private; preparation is reusable by any opted-in host.
|
|
7
|
+
export function registerDocumentPrintBlock(element, prepare) {
|
|
8
|
+
blocks.set(element, prepare)
|
|
9
|
+
return () => blocks.delete(element)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function setTemporaryAttribute(element, name, value, undo) {
|
|
13
|
+
const prior = element.getAttribute(name)
|
|
14
|
+
undo.push(() => prior === null ? element.removeAttribute(name) : element.setAttribute(name, prior))
|
|
15
|
+
value === null ? element.removeAttribute(name) : element.setAttribute(name, value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function prepareTables(root, undo, diagnostics) {
|
|
19
|
+
for (const table of root.querySelectorAll('table')) {
|
|
20
|
+
if (table.tHead || !table.tBodies.length) continue
|
|
21
|
+
const body = table.tBodies[0]
|
|
22
|
+
const rows = []
|
|
23
|
+
// A column header group is a contiguous leading run of all-TH rows. Row
|
|
24
|
+
// headers/mixed cells stay in tbody; never infer headers from bold styling.
|
|
25
|
+
for (const row of body.rows) {
|
|
26
|
+
if (!row.cells.length || !Array.from(row.cells).every(cell => cell.tagName === 'TH' &&
|
|
27
|
+
!['row', 'rowgroup'].includes(cell.getAttribute('scope')))) break
|
|
28
|
+
rows.push(row)
|
|
29
|
+
}
|
|
30
|
+
if (!rows.length) continue
|
|
31
|
+
if (rows.some((row, index) => Array.from(row.cells).some(cell => cell.rowSpan === 0 || cell.rowSpan + index > rows.length))) {
|
|
32
|
+
diagnostics.push({ code: 'table-header-span-unmapped' })
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
const following = rows.at(-1).nextSibling
|
|
36
|
+
const head = table.ownerDocument.createElement('thead')
|
|
37
|
+
body.before(head)
|
|
38
|
+
rows.forEach(row => head.append(row))
|
|
39
|
+
undo.push(() => { rows.forEach(row => body.insertBefore(row, following)); head.remove() })
|
|
40
|
+
for (const row of rows) for (const cell of row.cells) {
|
|
41
|
+
if (!cell.hasAttribute('scope')) setTemporaryAttribute(cell, 'scope', cell.colSpan > 1 ? 'colgroup' : 'col', undo)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function prepareEmbeds(root, undo, diagnostics) {
|
|
47
|
+
const doc = root.ownerDocument
|
|
48
|
+
for (const embed of root.querySelectorAll('.asd20-message-video, .asd20-tableau-embed')) {
|
|
49
|
+
const video = embed.classList.contains('asd20-message-video')
|
|
50
|
+
const source = video ? embed.dataset.src || embed.querySelector('iframe')?.src : embed.dataset.printSrc
|
|
51
|
+
const description = video ? embed.dataset.descriptiveText || embed.querySelector('.asd20-message-video__description')?.textContent :
|
|
52
|
+
embed.querySelector('.asd20-tableau-embed__description')?.textContent
|
|
53
|
+
const title = video ? embed.dataset.title || embed.querySelector('iframe')?.title : 'Interactive data visualization'
|
|
54
|
+
const alternative = doc.createElement('div')
|
|
55
|
+
alternative.className = 'asd20-print-only asd20-print-embed-alternative'
|
|
56
|
+
const text = doc.createElement('p')
|
|
57
|
+
text.textContent = `${video ? 'Video' : 'Interactive data'}: ${title || 'Untitled'}. ${description ? '' : 'A descriptive text alternative has not been supplied.'}`
|
|
58
|
+
alternative.append(text)
|
|
59
|
+
if (!description?.trim()) diagnostics.push({ code: 'embed-description-missing', source: source || '' })
|
|
60
|
+
let url
|
|
61
|
+
try { url = new URL(source) } catch { /* Report a missing safe source below. */ }
|
|
62
|
+
if (url && ['https:', 'http:'].includes(url.protocol)) {
|
|
63
|
+
const link = doc.createElement('a')
|
|
64
|
+
link.href = url.href
|
|
65
|
+
link.textContent = `${video ? 'Watch video' : 'View interactive data'}: ${url.href}`
|
|
66
|
+
alternative.append(link)
|
|
67
|
+
} else diagnostics.push({ code: 'embed-source-missing' })
|
|
68
|
+
embed.append(alternative)
|
|
69
|
+
undo.push(() => alternative.remove())
|
|
70
|
+
}
|
|
71
|
+
for (const iframe of root.querySelectorAll('iframe')) {
|
|
72
|
+
if (!iframe.closest('.asd20-message-video, .asd20-tableau-embed')) diagnostics.push({ code: 'embed-alternative-unmapped', source: iframe.src })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function beginDocumentPrint(root) {
|
|
77
|
+
if (!root?.hasAttribute('data-asd20-document-root')) throw new TypeError('Document printing requires an explicit document root.')
|
|
78
|
+
const doc = root.ownerDocument
|
|
79
|
+
if (sessions.has(doc)) throw new Error('A document print session is already active.')
|
|
80
|
+
const undo = []
|
|
81
|
+
const diagnostics = []
|
|
82
|
+
const session = { diagnostics, restore() {
|
|
83
|
+
if (sessions.get(doc) !== session) return
|
|
84
|
+
sessions.delete(doc)
|
|
85
|
+
undo.reverse().forEach(restore => restore())
|
|
86
|
+
} }
|
|
87
|
+
sessions.set(doc, session)
|
|
88
|
+
try {
|
|
89
|
+
setTemporaryAttribute(doc.documentElement, 'data-asd20-print-profile', 'document', undo)
|
|
90
|
+
setTemporaryAttribute(root, 'data-asd20-print-prepared', '', undo)
|
|
91
|
+
for (const stamp of root.querySelectorAll('[data-asd20-print-generated-at]')) {
|
|
92
|
+
if (stamp.hasAttribute('data-asd20-print-fixed-generated-at')) continue
|
|
93
|
+
const prior = stamp.textContent
|
|
94
|
+
stamp.textContent = `Generated: ${new Intl.DateTimeFormat('en-US', {
|
|
95
|
+
timeZone: 'America/Denver', dateStyle: 'medium', timeStyle: 'long',
|
|
96
|
+
}).format(new Date())}`
|
|
97
|
+
undo.push(() => { stamp.textContent = prior })
|
|
98
|
+
}
|
|
99
|
+
for (const element of [root, ...root.querySelectorAll('*')]) {
|
|
100
|
+
const prepare = blocks.get(element)
|
|
101
|
+
if (prepare) undo.push(prepare())
|
|
102
|
+
}
|
|
103
|
+
prepareTables(root, undo, diagnostics)
|
|
104
|
+
prepareEmbeds(root, undo, diagnostics)
|
|
105
|
+
if (root.querySelector('[data-cc-block]')) diagnostics.push({ code: 'rich-block-unresolved' })
|
|
106
|
+
for (const link of root.querySelectorAll('a[href]')) {
|
|
107
|
+
const href = link.getAttribute('href')
|
|
108
|
+
if (!href || href.startsWith('#') || link.closest('.asd20-print-contents') || link.hasAttribute('data-asd20-print-no-url')) continue
|
|
109
|
+
const url = new URL(href, doc.baseURI)
|
|
110
|
+
if (!['https:', 'http:'].includes(url.protocol) || link.textContent.includes(url.href)) continue
|
|
111
|
+
setTemporaryAttribute(link, 'data-asd20-print-source', url.href, undo)
|
|
112
|
+
}
|
|
113
|
+
for (const image of root.querySelectorAll('img')) setTemporaryAttribute(image, 'loading', 'eager', undo)
|
|
114
|
+
return session
|
|
115
|
+
} catch (error) { session.restore(); throw error }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function waitForAssets(root) {
|
|
119
|
+
await nextTick() // Includes legacy rich-block enhancement and print state updates.
|
|
120
|
+
const doc = root.ownerDocument
|
|
121
|
+
if (doc.fonts) {
|
|
122
|
+
await doc.fonts.ready
|
|
123
|
+
if (Array.from(doc.fonts).some(font => font.status === 'error')) throw new Error('A document font failed to load.')
|
|
124
|
+
}
|
|
125
|
+
await Promise.all(Array.from(root.querySelectorAll('img')).filter(image => !image.closest('[data-asd20-print-hide], .asd20-print-hide')).map(async image => {
|
|
126
|
+
if (typeof image.decode === 'function') await image.decode()
|
|
127
|
+
else if (!image.complete) await new Promise((resolve, reject) => {
|
|
128
|
+
image.addEventListener('load', resolve, { once: true })
|
|
129
|
+
image.addEventListener('error', reject, { once: true })
|
|
130
|
+
})
|
|
131
|
+
if (!image.naturalWidth) throw new Error(`A document image failed to load: ${image.getAttribute('src')}`)
|
|
132
|
+
}))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function prepareDocumentPrint(root, { timeoutMs = 15000 } = {}) {
|
|
136
|
+
await nextTick()
|
|
137
|
+
const session = beginDocumentPrint(root)
|
|
138
|
+
let timer
|
|
139
|
+
try {
|
|
140
|
+
await Promise.race([waitForAssets(root), new Promise((_resolve, reject) => {
|
|
141
|
+
timer = setTimeout(() => reject(new Error('Document asset readiness timed out.')), timeoutMs)
|
|
142
|
+
})])
|
|
143
|
+
if (!root.isConnected || sessions.get(root.ownerDocument) !== session) throw new Error('Document print preparation was cancelled.')
|
|
144
|
+
return session
|
|
145
|
+
} catch (error) { session.restore(); throw error }
|
|
146
|
+
finally { clearTimeout(timer) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function printDocument(root, options) {
|
|
150
|
+
const session = await prepareDocumentPrint(root, options)
|
|
151
|
+
const win = root.ownerDocument.defaultView
|
|
152
|
+
let onAfterPrint
|
|
153
|
+
try {
|
|
154
|
+
const finished = new Promise(resolve => {
|
|
155
|
+
onAfterPrint = resolve
|
|
156
|
+
win.addEventListener('afterprint', onAfterPrint, { once: true })
|
|
157
|
+
})
|
|
158
|
+
win.print()
|
|
159
|
+
await finished
|
|
160
|
+
} finally {
|
|
161
|
+
win.removeEventListener('afterprint', onAfterPrint)
|
|
162
|
+
session.restore()
|
|
163
|
+
}
|
|
164
|
+
return session.diagnostics
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Native beforeprint cannot await fonts/images. It synchronously expands semantic
|
|
168
|
+
// blocks; controlled capture and the host's Print button must await preparation.
|
|
169
|
+
export function installDocumentPrintLifecycle(root) {
|
|
170
|
+
const win = root.ownerDocument.defaultView
|
|
171
|
+
let nativeSession
|
|
172
|
+
const before = () => {
|
|
173
|
+
if (!sessions.has(root.ownerDocument)) nativeSession = beginDocumentPrint(root)
|
|
174
|
+
}
|
|
175
|
+
const after = () => { nativeSession?.restore(); nativeSession = null }
|
|
176
|
+
win.addEventListener('beforeprint', before)
|
|
177
|
+
win.addEventListener('afterprint', after)
|
|
178
|
+
return () => {
|
|
179
|
+
win.removeEventListener('beforeprint', before)
|
|
180
|
+
win.removeEventListener('afterprint', after)
|
|
181
|
+
after()
|
|
182
|
+
sessions.get(root.ownerDocument)?.restore()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { buildPublicationIntroduction } from './publicationIntroduction'
|
|
2
|
+
import { buildPublicationHeadingModels } from '../components/atoms/Asd20RichBodyContent/messageDocumentRenderer'
|
|
3
|
+
import { createPublicationHeadingPrefix } from '../components/atoms/Asd20RichBodyContent/headingContext'
|
|
4
|
+
import { parseFragment } from 'parse5'
|
|
5
|
+
|
|
6
|
+
export function documentImageUrls(html, baseUrl) {
|
|
7
|
+
const urls = new Set()
|
|
8
|
+
const walk = node => {
|
|
9
|
+
if (node.tagName === 'img') {
|
|
10
|
+
const src = node.attrs.find(attribute => attribute.name === 'src')?.value
|
|
11
|
+
if (src && !src.startsWith('data:')) urls.add(new URL(src, baseUrl).href)
|
|
12
|
+
}
|
|
13
|
+
for (const child of node.childNodes || []) walk(child)
|
|
14
|
+
}
|
|
15
|
+
walk(parseFragment(html || ''))
|
|
16
|
+
return [...urls]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The online handbook and the isolated PDF view share ordering, exclusions and
|
|
20
|
+
// heading planning. Rendering a document never changes the authored messages.
|
|
21
|
+
export function buildHandbookDocument({ messages = [], metaDescription = '', documentId = 'handbook', documentUrl = '', reservedIds = [] } = {}) {
|
|
22
|
+
const counts = new Map()
|
|
23
|
+
const entries = messages.slice(1).map((message, index) => {
|
|
24
|
+
const identity = String(message.id ?? `position-${index}`)
|
|
25
|
+
const ordinal = counts.get(identity) || 0
|
|
26
|
+
counts.set(identity, ordinal + 1)
|
|
27
|
+
const key = createPublicationHeadingPrefix(documentId, JSON.stringify([identity, ordinal]))
|
|
28
|
+
return { message, key, titleId: `${key}-title`, subtopicsId: `${key}-subtopics` }
|
|
29
|
+
})
|
|
30
|
+
const headingModels = buildPublicationHeadingModels({
|
|
31
|
+
documentUrl,
|
|
32
|
+
bodies: entries.map(entry => ({ bodyContent: entry.message.bodyContent, bodyJson: entry.message.bodyJson, headingLevelOffset: 1, headingIdPrefix: entry.key })),
|
|
33
|
+
reservedIds: ['main', 'handbook-content', 'handbook-topics', ...reservedIds, ...entries.flatMap(entry => [entry.titleId, entry.subtopicsId])],
|
|
34
|
+
})
|
|
35
|
+
const introduction = buildPublicationIntroduction(messages[0], metaDescription)
|
|
36
|
+
return {
|
|
37
|
+
entries, introduction, headingModels,
|
|
38
|
+
headings: entries.flatMap((entry, index) => [
|
|
39
|
+
{ id: entry.titleId, label: entry.message.heading || entry.message.title, authoredLevel: 2, renderedLevel: 2 },
|
|
40
|
+
...(headingModels.models[index]?.headings || []),
|
|
41
|
+
]),
|
|
42
|
+
diagnostics: [...introduction.diagnostics, ...headingModels.headingDiagnostics],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { parseFragment } from 'parse5'
|
|
2
|
+
|
|
3
|
+
// Only empty paragraphs/whitespace are editorially empty. Media, placeholders,
|
|
4
|
+
// links, tables, and otherwise unknown elements must remain visible to review.
|
|
5
|
+
function nonemptyHtml(html) {
|
|
6
|
+
const visit = node => {
|
|
7
|
+
if (node.nodeName === '#text') return Boolean(node.value.trim())
|
|
8
|
+
if (node.tagName && node.tagName !== 'p') return true
|
|
9
|
+
return (node.childNodes || []).some(visit)
|
|
10
|
+
}
|
|
11
|
+
return (parseFragment(String(html || '')).childNodes || []).some(visit)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function nonemptyJson(value) {
|
|
15
|
+
if (!value) return false
|
|
16
|
+
let doc = value
|
|
17
|
+
if (typeof value === 'string') {
|
|
18
|
+
try { doc = JSON.parse(value) } catch { return true }
|
|
19
|
+
}
|
|
20
|
+
if (doc?.version === 1 && Array.isArray(doc.blocks)) return doc.blocks.length > 0
|
|
21
|
+
const visit = node => {
|
|
22
|
+
if (node?.type === 'text') return Boolean(node.text?.trim())
|
|
23
|
+
if (!['doc', 'paragraph'].includes(node?.type)) return true
|
|
24
|
+
return (node.content || []).some(visit)
|
|
25
|
+
}
|
|
26
|
+
return visit(doc)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildPublicationIntroduction(firstMessage, metaDescription = '') {
|
|
30
|
+
const message = firstMessage || {}
|
|
31
|
+
const lead = message.shortDescription || message.summary || metaDescription || ''
|
|
32
|
+
const excludedFields = ['heading', 'title', 'bodyContent', 'bodyJson', 'longDescription', 'images', 'callsToAction', 'detailLink', 'detailLinkLabel']
|
|
33
|
+
const unmappedFields = []
|
|
34
|
+
for (const field of ['bodyContent', 'longDescription']) {
|
|
35
|
+
if (nonemptyHtml(message[field])) unmappedFields.push(field)
|
|
36
|
+
}
|
|
37
|
+
if (nonemptyJson(message.bodyJson)) unmappedFields.push('bodyJson')
|
|
38
|
+
for (const field of ['images', 'callsToAction']) {
|
|
39
|
+
if (message[field]?.length) unmappedFields.push(field)
|
|
40
|
+
}
|
|
41
|
+
// Detail links direct readers from wayfinding messages to detailed pages.
|
|
42
|
+
// The handbook already is that destination; their omission is intentional.
|
|
43
|
+
// Only explicitly typed detail links qualify. Other links still need review.
|
|
44
|
+
if (message.links?.some(item => (item?.link ?? item)?.type !== 'Detail Link')) unmappedFields.push('links')
|
|
45
|
+
if (message.shortDescription && nonemptyHtml(message.summary) && message.summary !== lead) unmappedFields.push('summary')
|
|
46
|
+
return {
|
|
47
|
+
messageId: message.id ?? null, mode: 'first-summary-lead', lead,
|
|
48
|
+
detailLinkPolicy: 'omit-wayfinding',
|
|
49
|
+
leadSource: message.shortDescription || message.summary ? 'summary' : metaDescription ? 'metaDescription' : 'none',
|
|
50
|
+
excludedFields: [...excludedFields, 'links', ...(message.shortDescription ? ['summary'] : [])],
|
|
51
|
+
diagnostics: unmappedFields.length ? [{ code: 'introduction-content-unmapped', fields: unmappedFields }] : [],
|
|
52
|
+
}
|
|
53
|
+
}
|
package/templates.js
CHANGED
|
@@ -27,3 +27,4 @@ export { default as Asd20SearchAppTemplate } from './src/components/templates/As
|
|
|
27
27
|
export { default as Asd20WayfindingAlternateTemplate } from './src/components/templates/Asd20WayfindingAlternateTemplate/index.vue'
|
|
28
28
|
export { default as Asd20WayfindingImageTemplate } from './src/components/templates/Asd20WayfindingImageTemplate/index.vue'
|
|
29
29
|
export { default as Asd20WayfindingTemplate } from './src/components/templates/Asd20WayfindingTemplate/index.vue'
|
|
30
|
+
export { default as Asd20HandbookDocument } from './src/components/templates/Asd20HandbookDocument/index.vue'
|