@asd20/ui-next 2.30.0 → 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.
@@ -1,3 +1,10 @@
1
+ import {
2
+ buildHeadingIdPlan,
3
+ createHeadingSession,
4
+ contextualAccordionItemKey,
5
+ normalizeHeadingContext,
6
+ } from './headingContext'
7
+
1
8
  const MESSAGE_DOCUMENT_SCHEMA_VERSION = 1
2
9
 
3
10
  export const BODY_JSON_FORMAT = Object.freeze({
@@ -690,7 +697,7 @@ function attribute(name, value, preserveEmpty = false) {
690
697
  : ` ${name}="${escapeHtml(value)}"`
691
698
  }
692
699
 
693
- function renderMarkedText(node) {
700
+ function renderMarkedText(node, context, path) {
694
701
  let html = escapeHtml(node.text)
695
702
  const marks = [...(node.marks || [])].sort(
696
703
  (left, right) =>
@@ -705,7 +712,7 @@ function renderMarkedText(node) {
705
712
  else if (mark.type === 'subscript') html = `<sub>${html}</sub>`
706
713
  else if (mark.type === 'superscript') html = `<sup>${html}</sup>`
707
714
  else if (mark.type === 'link') {
708
- html = `<a${attribute('href', mark.attrs.href)}${attribute(
715
+ html = `<a${attribute('href', context ? context.href(mark.attrs.href, `${path}-link`) : mark.attrs.href)}${attribute(
709
716
  'target',
710
717
  mark.attrs.target
711
718
  )}${attribute('rel', mark.attrs.rel)}${attribute(
@@ -717,11 +724,19 @@ function renderMarkedText(node) {
717
724
  return html
718
725
  }
719
726
 
720
- function renderChildren(node) {
721
- return (node.content || []).map(renderStandardNode).join('')
727
+ function renderChildren(node, context, path, accordionItemId) {
728
+ return (node.content || []).map((child, index) =>
729
+ renderStandardNode(child, context, `${path}-${index}`, accordionItemId)
730
+ ).join('')
731
+ }
732
+
733
+ function canonicalHeadingLabel(node) {
734
+ if (node.type === 'text') return node.text
735
+ if (node.type === 'hardBreak') return ' '
736
+ return (node.content || []).map(canonicalHeadingLabel).join('')
722
737
  }
723
738
 
724
- function renderTableCell(node, tagName) {
739
+ function renderTableCell(node, tagName, context, path, accordionItemId) {
725
740
  const colspan = node.attrs?.colspan ?? 1
726
741
  const rowspan = node.attrs?.rowspan ?? 1
727
742
  const colwidth = node.attrs?.colwidth
@@ -734,10 +749,10 @@ function renderTableCell(node, tagName) {
734
749
  Array.isArray(colwidth) ? attribute('colwidth', colwidth.join(',')) : ''
735
750
  }${align ? attribute('style', `text-align: ${align}`) : ''}${
736
751
  scope ? attribute('scope', scope) : ''
737
- }>${renderChildren(node)}</${tagName}>`
752
+ }>${renderChildren(node, context, path, accordionItemId)}</${tagName}>`
738
753
  }
739
754
 
740
- function renderTable(node) {
755
+ function renderTable(node, context, path, accordionItemId) {
741
756
  const minimumWidth = 25
742
757
  const firstRow = node.content[0]
743
758
  const columns = firstRow.content.flatMap(cell => {
@@ -767,7 +782,7 @@ function renderTable(node) {
767
782
  'style',
768
783
  tableStyle
769
784
  )}>${caption}<colgroup>${colgroup}</colgroup><tbody>${renderChildren(
770
- node
785
+ node, context, path, accordionItemId
771
786
  )}</tbody></table></div>`
772
787
  }
773
788
 
@@ -782,34 +797,41 @@ function renderImage(attrs, className = '') {
782
797
  )}${attribute('height', attrs.height)}>`
783
798
  }
784
799
 
785
- function renderStandardNode(node) {
786
- if (node.type === 'text') return renderMarkedText(node)
800
+ function renderStandardNode(node, context, path, accordionItemId) {
801
+ if (node.type === 'text') return renderMarkedText(node, context, path)
787
802
  if (node.type === 'hardBreak') return '<br>'
788
- if (node.type === 'paragraph') return `<p>${renderChildren(node)}</p>`
803
+ if (node.type === 'paragraph') return `<p>${renderChildren(node, context, path, accordionItemId)}</p>`
789
804
  if (node.type === 'heading') {
790
- return `<h${node.attrs.level}>${renderChildren(node)}</h${
805
+ if (context) {
806
+ const descriptor = context.heading({
807
+ authoredLevel: node.attrs.level, label: canonicalHeadingLabel(node),
808
+ sourcePath: path, accordionItemId,
809
+ })
810
+ return `<h${descriptor.renderedLevel}${attribute('id', descriptor.id)}>${renderChildren(node, context, path, accordionItemId)}</h${descriptor.renderedLevel}>`
811
+ }
812
+ return `<h${node.attrs.level}>${renderChildren(node, context, path, accordionItemId)}</h${
791
813
  node.attrs.level
792
814
  }>`
793
815
  }
794
816
  if (node.type === 'blockquote') {
795
- return `<blockquote>${renderChildren(node)}</blockquote>`
817
+ return `<blockquote>${renderChildren(node, context, path, accordionItemId)}</blockquote>`
796
818
  }
797
819
  if (node.type === 'codeBlock') {
798
820
  const languageClass = node.attrs?.language
799
821
  ? attribute('class', `language-${node.attrs.language}`)
800
822
  : ''
801
- return `<pre><code${languageClass}>${renderChildren(node)}</code></pre>`
823
+ return `<pre><code${languageClass}>${renderChildren(node, context, path, accordionItemId)}</code></pre>`
802
824
  }
803
825
  if (node.type === 'horizontalRule') return '<hr>'
804
- if (node.type === 'bulletList') return `<ul>${renderChildren(node)}</ul>`
826
+ if (node.type === 'bulletList') return `<ul>${renderChildren(node, context, path, accordionItemId)}</ul>`
805
827
  if (node.type === 'orderedList') {
806
828
  const start = node.attrs?.start
807
829
  const type = node.attrs?.type
808
830
  return `<ol${start && start !== 1 ? attribute('start', start) : ''}${
809
831
  type && type !== '1' ? attribute('type', type) : ''
810
- }>${renderChildren(node)}</ol>`
832
+ }>${renderChildren(node, context, path, accordionItemId)}</ol>`
811
833
  }
812
- if (node.type === 'listItem') return `<li>${renderChildren(node)}</li>`
834
+ if (node.type === 'listItem') return `<li>${renderChildren(node, context, path, accordionItemId)}</li>`
813
835
  if (node.type === 'image') {
814
836
  return renderImage(node.attrs, 'asd20-message-image')
815
837
  }
@@ -844,10 +866,10 @@ function renderStandardNode(node) {
844
866
  : ''
845
867
  }</div>`
846
868
  }
847
- if (node.type === 'table') return renderTable(node)
848
- if (node.type === 'tableRow') return `<tr>${renderChildren(node)}</tr>`
849
- if (node.type === 'tableCell') return renderTableCell(node, 'td')
850
- if (node.type === 'tableHeader') return renderTableCell(node, 'th')
869
+ if (node.type === 'table') return renderTable(node, context, path, accordionItemId)
870
+ if (node.type === 'tableRow') return `<tr>${renderChildren(node, context, path, accordionItemId)}</tr>`
871
+ if (node.type === 'tableCell') return renderTableCell(node, 'td', context, path, accordionItemId)
872
+ if (node.type === 'tableHeader') return renderTableCell(node, 'th', context, path, accordionItemId)
851
873
  return ''
852
874
  }
853
875
 
@@ -878,47 +900,87 @@ function buildLegacyRenderNodes(bodyHtml, blocks) {
878
900
  return nodes
879
901
  }
880
902
 
881
- function accordionToRenderBlock(node) {
903
+ function accordionToRenderBlock(node, context, path) {
882
904
  return {
883
905
  id: node.attrs?.blockId || '',
884
906
  type: 'accordion',
885
- items: node.content.map(item => {
886
- const isTableau = item.content[0]?.type === 'tableauBlock'
887
- return isTableau
888
- ? {
889
- id: item.attrs?.itemId || '',
890
- heading: item.attrs?.heading || '',
891
- opened: Boolean(item.attrs?.opened),
892
- contentType: 'tableau',
893
- tableau: { ...item.content[0].attrs },
894
- }
895
- : {
896
- id: item.attrs?.itemId || '',
897
- heading: item.attrs?.heading || '',
898
- opened: Boolean(item.attrs?.opened),
899
- contentType: 'text',
900
- bodyHtml: item.content.map(renderStandardNode).join(''),
901
- }
907
+ items: node.content.map((item, index) => {
908
+ const renderItem = () => {
909
+ const itemPath = `${path}-${index}`
910
+ const contextual = context ? contextualAccordionHeading(context, item.attrs?.heading || '', itemPath) : {}
911
+ const isTableau = item.content[0]?.type === 'tableauBlock'
912
+ return isTableau
913
+ ? {
914
+ id: item.attrs?.itemId || '',
915
+ heading: item.attrs?.heading || '',
916
+ ...contextual,
917
+ opened: Boolean(item.attrs?.opened),
918
+ contentType: 'tableau',
919
+ tableau: { ...item.content[0].attrs },
920
+ }
921
+ : {
922
+ id: item.attrs?.itemId || '',
923
+ heading: item.attrs?.heading || '',
924
+ ...contextual,
925
+ opened: Boolean(item.attrs?.opened),
926
+ contentType: 'text',
927
+ bodyHtml: item.content.map((child, childIndex) => renderStandardNode(
928
+ child, context, `${itemPath}-${childIndex}`, context ? itemPath : null
929
+ )).join(''),
930
+ }
931
+ }
932
+ return context ? context.scope(renderItem) : renderItem()
902
933
  }),
903
934
  }
904
935
  }
905
936
 
906
- function buildTiptapRenderNodes(messageDocument) {
937
+ function contextualAccordionHeading(context, label, path) {
938
+ const descriptor = context.heading({
939
+ authoredLevel: 3, label, sourcePath: `${path}-heading`,
940
+ kind: 'accordion-item', accordionItemId: path,
941
+ })
942
+ const panel = context.anchor(`${path}-panel`, null,
943
+ `${context.context.idPrefix || 'body'}-${path}-panel`, 'panel')
944
+ return {
945
+ [contextualAccordionItemKey]: true,
946
+ headingContextApplied: true,
947
+ headingTag: `h${descriptor.renderedLevel}`,
948
+ headingId: descriptor.id,
949
+ contentId: panel.id,
950
+ headingDescriptor: descriptor,
951
+ }
952
+ }
953
+
954
+ function contextualLegacyAccordion(block, context, path) {
955
+ return {
956
+ ...block,
957
+ items: (Array.isArray(block.items) ? block.items : []).map((item, index) => context.scope(() => {
958
+ if (item?.[contextualAccordionItemKey]) return item
959
+ const itemPath = `${path}-item-${index}`
960
+ const contextual = contextualAccordionHeading(context, item?.heading || '', itemPath)
961
+ const body = context.html(item?.bodyHtml || '', `${itemPath}-body`, null, itemPath)
962
+ return { ...item, ...contextual, bodyHtml: body.html }
963
+ })),
964
+ }
965
+ }
966
+
967
+ function buildTiptapRenderNodes(messageDocument, context) {
907
968
  const nodes = []
908
969
  let standardContent = []
909
970
  const flushStandardContent = () => {
910
971
  if (!standardContent.length) return
911
972
  nodes.push({
912
973
  type: 'html',
913
- html: standardContent.map(renderStandardNode).join(''),
974
+ html: standardContent.map(({ node, path }) => renderStandardNode(node, context, path)).join(''),
914
975
  })
915
976
  standardContent = []
916
977
  }
917
978
 
918
- messageDocument.content.forEach(node => {
979
+ messageDocument.content.forEach((node, index) => {
980
+ const path = `node-${index}`
919
981
  if (node.type === 'accordionBlock') {
920
982
  flushStandardContent()
921
- nodes.push({ type: 'accordion', block: accordionToRenderBlock(node) })
983
+ nodes.push({ type: 'accordion', block: accordionToRenderBlock(node, context, path) })
922
984
  } else if (node.type === 'tableauBlock') {
923
985
  flushStandardContent()
924
986
  nodes.push({
@@ -930,7 +992,7 @@ function buildTiptapRenderNodes(messageDocument) {
930
992
  },
931
993
  })
932
994
  } else {
933
- standardContent.push(node)
995
+ standardContent.push({ node, path })
934
996
  }
935
997
  })
936
998
  flushStandardContent()
@@ -954,7 +1016,7 @@ export function containsBodyBlockPlaceholder(html) {
954
1016
  return new RegExp(LEGACY_PLACEHOLDER_PATTERN).test(html)
955
1017
  }
956
1018
 
957
- export function buildRichBodyRenderModel({
1019
+ function buildBaseRichBodyRenderModel({
958
1020
  bodyContent = '',
959
1021
  bodyJson = '',
960
1022
  } = {}) {
@@ -996,3 +1058,95 @@ export function buildRichBodyRenderModel({
996
1058
  nodes: buildTiptapRenderNodes(detected.value),
997
1059
  }
998
1060
  }
1061
+
1062
+ function renderContextualBody(input, baseModel, session) {
1063
+ if (baseModel.status === MESSAGE_DOCUMENT_STATUS.SUPPORTED) {
1064
+ const { parsed } = parseBodyJson(input.bodyJson)
1065
+ const nodes = buildTiptapRenderNodes(parsed, session)
1066
+ return { ...baseModel, nodes, initialNodes: nodes }
1067
+ }
1068
+ const blocks = baseModel.status === MESSAGE_DOCUMENT_STATUS.LEGACY ? baseModel.blocks : []
1069
+ const blockMap = new Map(blocks.map(block => [block.id, block]))
1070
+ const transformed = session.html(input.bodyContent || '', 'body', (type, id, path) => {
1071
+ const block = blockMap.get(id)
1072
+ if (!block || block.type !== type) return null
1073
+ if (type === 'accordion') {
1074
+ return { type, block: contextualLegacyAccordion(block, session, `html-body-block-${path}`) }
1075
+ }
1076
+ return type === 'tableau' ? { type, block: { ...block } } : null
1077
+ })
1078
+ return {
1079
+ ...baseModel,
1080
+ nodes: transformed.nodes,
1081
+ initialNodes: transformed.html ? [{ type: 'html', html: transformed.html }] : [],
1082
+ }
1083
+ }
1084
+
1085
+ function finalizeContextualModel(render, context, suppliedPlan = null) {
1086
+ let plan = suppliedPlan || context.idPlan
1087
+ if (!plan) {
1088
+ const draft = createHeadingSession(context)
1089
+ render(draft)
1090
+ plan = buildHeadingIdPlan([draft], context.reservedIds || [])
1091
+ }
1092
+ const session = createHeadingSession({ ...context, idPlan: plan })
1093
+ const model = render(session)
1094
+ const prefix = context.idPrefix || 'body'
1095
+ if (!suppliedPlan && !context.idPlan) session.report('document-heading-plan-unavailable', prefix)
1096
+ const planDiagnostics = plan.diagnostics.filter(row => row.sourceKey.startsWith(`${prefix}:`))
1097
+ for (const descriptor of session.headings) {
1098
+ descriptor.diagnostics.push(...planDiagnostics.filter(row => row.sourceKey === descriptor.key).map(row => row.code))
1099
+ }
1100
+ return {
1101
+ ...model, headings: session.headings,
1102
+ headingDiagnostics: [...session.diagnostics, ...planDiagnostics],
1103
+ headingContextApplied: true,
1104
+ documentHeadingPlanAvailable: Boolean(suppliedPlan || context.idPlan),
1105
+ }
1106
+ }
1107
+
1108
+ export function buildRichBodyRenderModel(input = {}) {
1109
+ const baseModel = buildBaseRichBodyRenderModel(input)
1110
+ const context = normalizeHeadingContext(input.headingContext)
1111
+ if (!context.active) {
1112
+ return { ...baseModel, headings: [], headingDiagnostics: [], headingContextApplied: false }
1113
+ }
1114
+ return finalizeContextualModel(session => renderContextualBody(input, baseModel, session), context)
1115
+ }
1116
+
1117
+ // This prepass is deliberately independent of Vue instances and mounted topics.
1118
+ export function buildPublicationHeadingModels({ bodies = [], reservedIds = [], documentUrl } = {}) {
1119
+ const prefixes = new Set()
1120
+ const prepared = bodies.map(body => {
1121
+ const prefix = body.headingIdPrefix
1122
+ if (typeof prefix !== 'string' || !prefix || prefixes.has(prefix)) {
1123
+ throw new TypeError('Publication bodies require unique, nonempty headingIdPrefix values.')
1124
+ }
1125
+ prefixes.add(prefix)
1126
+ const context = normalizeHeadingContext({
1127
+ levelOffset: body.headingLevelOffset === undefined ? 0 : body.headingLevelOffset,
1128
+ idPrefix: prefix, documentUrl,
1129
+ })
1130
+ const baseModel = buildBaseRichBodyRenderModel(body)
1131
+ const render = session => renderContextualBody(body, baseModel, session)
1132
+ const session = createHeadingSession(context)
1133
+ render(session)
1134
+ return { context, render, session }
1135
+ })
1136
+ const idPlan = buildHeadingIdPlan(prepared.map(row => row.session), reservedIds)
1137
+ const models = prepared.map(row => finalizeContextualModel(row.render, row.context, idPlan))
1138
+ return {
1139
+ idPlan, models,
1140
+ headings: models.flatMap(model => model.headings),
1141
+ headingDiagnostics: models.flatMap(model => model.headingDiagnostics),
1142
+ }
1143
+ }
1144
+
1145
+ export function buildBodyAccordionRenderModel({ items = [], headingContext = {} } = {}) {
1146
+ const context = normalizeHeadingContext(headingContext)
1147
+ if (!context.active) return { items, headingContextApplied: false }
1148
+ if (items.every(item => item?.[contextualAccordionItemKey])) return { items, headingContextApplied: true }
1149
+ return finalizeContextualModel(session => ({
1150
+ items: contextualLegacyAccordion({ items }, session, 'accordion').items,
1151
+ }), context)
1152
+ }
@@ -9,7 +9,7 @@
9
9
  >
10
10
  <component
11
11
  :is="tag"
12
- :id="headingId"
12
+ :id="resolvedHeadingId"
13
13
  class="asd20-accordion__heading"
14
14
  >
15
15
  <span
@@ -32,10 +32,10 @@
32
32
  static
33
33
  />
34
34
  <asd20-button
35
- :aria-labelledby="headingId"
36
- :aria-controls="contentId"
37
- :aria-expanded="open"
38
- :icon-angle="open ? 180 : 90"
35
+ :aria-labelledby="resolvedHeadingId"
36
+ :aria-controls="resolvedContentId"
37
+ :aria-expanded="isOpen"
38
+ :icon-angle="isOpen ? 180 : 90"
39
39
  icon="chevron"
40
40
  size="sm"
41
41
  transparent
@@ -44,12 +44,12 @@
44
44
  </div>
45
45
 
46
46
  <div
47
- :id="contentId"
47
+ :id="resolvedContentId"
48
48
  ref="content"
49
- :aria-hidden="open ? 'false' : 'true'"
50
- :inert="open ? null : ''"
49
+ :aria-hidden="isOpen ? 'false' : 'true'"
50
+ :inert="isOpen ? null : ''"
51
51
  :class="{
52
- 'asd20-accordion__content--open': open,
52
+ 'asd20-accordion__content--open': isOpen,
53
53
  }"
54
54
  class="asd20-accordion__content"
55
55
  >
@@ -70,6 +70,7 @@
70
70
  import Asd20Badge from '../../atoms/Asd20Badge'
71
71
  import Asd20Button from '../../atoms/Asd20Button'
72
72
  import Asd20Viewport from '../../atoms/Asd20Viewport'
73
+ import { registerDocumentPrintBlock } from '../../../helpers/documentPrint'
73
74
  import createComponentInstanceId from '../../../utils/createComponentInstanceId'
74
75
 
75
76
  const FOCUSABLE_SELECTOR = [
@@ -88,6 +89,8 @@ export default {
88
89
  tag: { type: String, default: 'h3' },
89
90
  label: { type: String, default: '' },
90
91
  heading: { type: String, default: '' },
92
+ headingId: { type: String, default: '' },
93
+ contentId: { type: String, default: '' },
91
94
  description: { type: String, default: '' },
92
95
  badge: { type: Number, default: -1 },
93
96
  opened: { type: Boolean, default: false },
@@ -99,14 +102,16 @@ export default {
99
102
  },
100
103
  data: () => ({
101
104
  open: false,
105
+ printExpanded: false,
102
106
  instanceId: createComponentInstanceId('accordion'),
103
107
  }),
104
108
  computed: {
105
- headingId() {
106
- return `${this.instanceId}-heading`
109
+ isOpen() { return this.open || this.printExpanded },
110
+ resolvedHeadingId() {
111
+ return this.headingId || `${this.instanceId}-heading`
107
112
  },
108
- contentId() {
109
- return `${this.instanceId}-content`
113
+ resolvedContentId() {
114
+ return this.contentId || `${this.instanceId}-content`
110
115
  },
111
116
  classes() {
112
117
  return {
@@ -119,15 +124,34 @@ export default {
119
124
  this.open = this.opened
120
125
  },
121
126
  mounted() {
127
+ this.unregisterPrintBlock = registerDocumentPrintBlock(this.$el, () => {
128
+ this.printExpanded = true
129
+ this.syncPrintSemantics()
130
+ return () => {
131
+ this.printExpanded = false
132
+ this.syncPrintSemantics()
133
+ }
134
+ })
122
135
  this.syncCollapsedContentInteractivity()
123
136
  },
124
137
  updated() {
125
138
  this.syncCollapsedContentInteractivity()
126
139
  },
127
140
  beforeUnmount() {
141
+ this.unregisterPrintBlock?.()
128
142
  this.restoreCollapsedContentInteractivity()
129
143
  },
130
144
  methods: {
145
+ syncPrintSemantics() {
146
+ // beforeprint is synchronous: update accessibility state before Vue's tick.
147
+ const panel = this.$refs.content
148
+ panel?.setAttribute('aria-hidden', this.isOpen ? 'false' : 'true')
149
+ if (this.isOpen) panel?.removeAttribute('inert')
150
+ else panel?.setAttribute('inert', '')
151
+ panel?.classList.toggle('asd20-accordion__content--open', this.isOpen)
152
+ this.$el.querySelector('[aria-controls]')?.setAttribute('aria-expanded', String(this.isOpen))
153
+ this.syncCollapsedContentInteractivity()
154
+ },
131
155
  toggleOpen: function() {
132
156
  this.open = !this.open
133
157
  if (this.open) this.$emit('open')
@@ -140,7 +164,7 @@ export default {
140
164
  const focusableElements = content.querySelectorAll(FOCUSABLE_SELECTOR)
141
165
 
142
166
  focusableElements.forEach(element => {
143
- if (this.open) {
167
+ if (this.isOpen) {
144
168
  if (!Object.prototype.hasOwnProperty.call(element.dataset, 'accordionOriginalTabindex')) {
145
169
  return
146
170
  }
@@ -5,9 +5,12 @@
5
5
  >
6
6
  <asd20-accordion
7
7
  v-for="item in normalizedItems"
8
- :key="item.id"
8
+ :key="item.headingId || item.id"
9
9
  :heading="item.heading"
10
10
  :opened="item.opened"
11
+ :tag="item.headingTag || 'h3'"
12
+ :heading-id="item.headingId || ''"
13
+ :content-id="item.contentId || ''"
11
14
  class="asd20-body-accordion__item"
12
15
  >
13
16
  <div
@@ -30,13 +33,21 @@
30
33
  <script>
31
34
  import Asd20Accordion from '../Asd20Accordion'
32
35
  import Asd20TableauEmbed from '../Asd20TableauEmbed'
36
+ import { buildBodyAccordionRenderModel } from '../../atoms/Asd20RichBodyContent/messageDocumentRenderer'
37
+ import { publicationHeadingPlanKey } from '../../atoms/Asd20RichBodyContent/headingContext'
33
38
 
34
39
  export default {
35
40
  name: 'Asd20BodyAccordion',
36
41
 
37
42
  components: { Asd20Accordion, Asd20TableauEmbed },
38
43
 
44
+ inject: {
45
+ publicationHeadingPlan: { from: publicationHeadingPlanKey, default: null },
46
+ },
47
+
39
48
  props: {
49
+ headingLevelOffset: { type: Number, default: 0 },
50
+ headingIdPrefix: { type: String, default: '' },
40
51
  items: {
41
52
  type: Array,
42
53
  default: () => [],
@@ -45,10 +56,20 @@ export default {
45
56
 
46
57
  computed: {
47
58
  normalizedItems() {
48
- return (Array.isArray(this.items) ? this.items : [])
59
+ const model = buildBodyAccordionRenderModel({
60
+ items: Array.isArray(this.items) ? this.items : [],
61
+ headingContext: {
62
+ levelOffset: this.headingLevelOffset, idPrefix: this.headingIdPrefix,
63
+ idPlan: this.publicationHeadingPlan?.(),
64
+ },
65
+ })
66
+ return model.items
49
67
  .map((item, index) => ({
50
68
  id: item?.id || `accordion-item-${index}`,
51
69
  heading: item?.heading || '',
70
+ headingTag: model.headingContextApplied ? item?.headingTag || '' : '',
71
+ headingId: model.headingContextApplied ? item?.headingId || '' : '',
72
+ contentId: model.headingContextApplied ? item?.contentId || '' : '',
52
73
  contentType: item?.contentType === 'tableau' ? 'tableau' : 'text',
53
74
  bodyHtml: item?.bodyHtml || '',
54
75
  opened: Boolean(item?.opened),
@@ -0,0 +1,42 @@
1
+ <template>
2
+ <nav
3
+ class="asd20-print-contents asd20-print-only"
4
+ :aria-label="label"
5
+ >
6
+ <p class="asd20-print-contents__label">
7
+ {{ label }}
8
+ </p>
9
+ <ol>
10
+ <li
11
+ v-for="heading in entries"
12
+ :key="heading.id"
13
+ :style="{ marginInlineStart: `${Math.max(0, heading.renderedLevel - 2)}em` }"
14
+ >
15
+ <a :href="`#${heading.id}`">
16
+ <span>{{ heading.label }}</span>
17
+ <span
18
+ class="asd20-print-contents__page"
19
+ :data-asd20-toc-target="heading.id"
20
+ />
21
+ </a>
22
+ </li>
23
+ </ol>
24
+ </nav>
25
+ </template>
26
+
27
+ <script>
28
+ export default {
29
+ name: 'Asd20PrintContents',
30
+ props: {
31
+ headings: { type: Array, default: () => [] },
32
+ label: { type: String, default: 'Contents' },
33
+ },
34
+ computed: {
35
+ entries() { return this.headings.filter(heading => heading.id && heading.label) },
36
+ },
37
+ }
38
+ </script>
39
+
40
+ <style>
41
+ .asd20-print-only { display: none; }
42
+ </style>
@@ -0,0 +1,54 @@
1
+ <template>
2
+ <header class="asd20-print-header asd20-print-only">
3
+ <h1 v-if="title">
4
+ {{ title }}
5
+ </h1>
6
+ <p v-if="organizationTitle">
7
+ {{ organizationTitle }}
8
+ </p>
9
+ <p v-if="academicYear">
10
+ Academic year: {{ academicYear }}
11
+ </p>
12
+ <p v-if="editorialRevisedAt">
13
+ Content revised: {{ editorialRevisedAt }}
14
+ </p>
15
+ <p
16
+ data-asd20-print-generated-at
17
+ :data-asd20-print-fixed-generated-at="generatedAt || undefined"
18
+ >
19
+ Generated: {{ formattedGeneratedAt }}
20
+ </p>
21
+ <p v-if="canonicalUrl">
22
+ The online document remains authoritative:
23
+ <a :href="canonicalUrl">{{ canonicalUrl }}</a>
24
+ </p>
25
+ </header>
26
+ </template>
27
+
28
+ <script>
29
+ export default {
30
+ name: 'Asd20PrintHeader',
31
+ props: {
32
+ // Leave empty when the host already owns its document H1.
33
+ title: { type: String, default: '' },
34
+ organizationTitle: { type: String, default: '' },
35
+ academicYear: { type: String, default: '' },
36
+ editorialRevisedAt: { type: String, default: '' },
37
+ generatedAt: { type: String, default: '' },
38
+ canonicalUrl: { type: String, default: '' },
39
+ },
40
+ computed: {
41
+ formattedGeneratedAt() {
42
+ if (!this.generatedAt) return ''
43
+ const date = new Date(this.generatedAt)
44
+ return Number.isNaN(date.getTime()) ? this.generatedAt : new Intl.DateTimeFormat('en-US', {
45
+ timeZone: 'America/Denver', dateStyle: 'medium', timeStyle: 'long',
46
+ }).format(date)
47
+ },
48
+ },
49
+ }
50
+ </script>
51
+
52
+ <style>
53
+ .asd20-print-only { display: none; }
54
+ </style>