PageSourceSearch

https://www.westpac.com.au/blocks/disclaimer/disclaimer.js

js westpac.com.au collected 2026-09-24 08:29:42 UTC 17,025 bytes, 504 lines download raw bytes

1import {
2  getCellText,
3  getCellHTML,
4  getCellUrl,
5  getFirstCell,
6  isUniversalEditorMode,
7  togglePanel,
8} from '../../scripts/utility/shared.js';
9import { DEFAULT_SITE_CONTENT_ROOT } from '../../scripts/disclaimer-config.js';
10
11const blockDataByElement = new WeakMap();
12const blockClickHandlers = new WeakMap();
13const triggerHandlers = new WeakMap();
14const activeSourceLinksByBlock = new WeakMap();
15const pendingHighlightWorkByBlock = new WeakMap();
16const STABLE_CONTENT_SECTION_CLASSES = [
17  'disclaimer-top-cf',
18  'disclaimer-top',
19  'disclaimer-auto',
20  'disclaimer-bottom-cf',
21  'disclaimer-bottom',
22];
23
24/**
25 * Extract disclaimer block data from authored block DOM.
26 *
27 * Block-level model: each field = one row (not cells within a row).
28 * Row 0: heading | Row 1: topCFPath | Row 2: text-topContent
29 * Row 3: bottomCFPath | Row 4: text-bottomContent
30 */
31function extractData(block) {
32  const retainedData = blockDataByElement.get(block);
33  if (retainedData) return retainedData;
34
35  const rows = [...block.children];
36
37  if (!rows.length) {
38    const data = {
39      heading: 'Things you should know',
40      topCFPath: '',
41      topContent: '',
42      bottomCFPath: '',
43      bottomContent: '',
44    };
45    blockDataByElement.set(block, data);
46    return data;
47  }
48
49  const heading = getCellText(getFirstCell(rows[0])) || 'Things you should know';
50  const topCFPath = getCellUrl(getFirstCell(rows[1]));
51  const topContent = getCellHTML(getFirstCell(rows[2])) || '';
52  const bottomCFPath = getCellUrl(getFirstCell(rows[3]));
53  const bottomContent = getCellHTML(getFirstCell(rows[4])) || '';
54  const data = {
55    heading,
56    topCFPath,
57    topContent,
58    bottomCFPath,
59    bottomContent,
60  };
61  blockDataByElement.set(block, data);
62
63  return data;
64}
65
66/**
67 * Render the disclaimer block HTML string.
68 * Returns the inner HTML for the disclaimer card.
69 */
70function renderHTML(data) {
71  const { heading, topContent, bottomContent } = data;
72  const { response } = data;
73  const resolvedTopCF = response.top.map(({ html }) => html || '').join('');
74  const resolvedBottomCF = response.bottom.map(({ html }) => html || '').join('');
75  const disclaimers = response.disclaimers.map(({ number, html }) => ({
76    index: number,
77    html,
78  }));
79
80  let topCFSection = '';
81  if (resolvedTopCF) {
82    topCFSection = `<div class="disclaimer-top-cf">${resolvedTopCF}</div>`;
83  }
84
85  let topRTESection = '';
86  if (topContent) {
87    topRTESection = `<div class="disclaimer-top">${topContent}</div>`;
88  }
89
90  let autoSection = '';
91  if (disclaimers && disclaimers.length > 0) {
92    const items = disclaimers
93      .map(
94        ({ index, html }) => `
95        <div id="disclaimer-${index}" class="disclaimer-item" tabindex="-1">
96          <span class="disclaimer-index">[${index}]</span>
97          <div class="disclaimer-item-content">
98            ${html}
99            <a href="#" class="disclaimer-return" aria-label="Return to footnote ${index}" data-index="${index}">Return<span class="sr-only"> to footnote ${index}</span><svg class="disclaimer-return-icon" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M6 3.8683L10.9409 8.8091L12 7.75L6 1.75L0 7.75L1.0591 8.8092L6 3.8683Z" fill="currentColor"/></svg></a>
100          </div>
101        </div>`,
102      )
103      .join('');
104
105    autoSection = `<div class="disclaimer-auto" role="region" aria-label="Disclaimer section">${items}</div>`;
106  }
107
108  let bottomCFSection = '';
109  if (resolvedBottomCF) {
110    bottomCFSection = `<div class="disclaimer-bottom-cf">${resolvedBottomCF}</div>`;
111  }
112
113  let bottomRTESection = '';
114  if (bottomContent) {
115    bottomRTESection = `<div class="disclaimer-bottom">${bottomContent}</div>`;
116  }
117
118  return `
119    <div>
120      <div class="disclaimer-card">
121        <button class="disclaimer-header" aria-expanded="false" data-analytics-action="action card: ${heading.replace(/"/g, '&quot;').toLowerCase()}">
122          <h2 class="disclaimer-heading">${heading}</h2>
123          <span class="disclaimer-icon" aria-hidden="true"></span>
124        </button>
125        <div id="disclaimer-content" class="disclaimer-content" hidden>
126          ${topCFSection}
127          ${topRTESection}
128          ${autoSection}
129          ${bottomCFSection}
130          ${bottomRTESection}
131        </div>
132      </div>
133    </div>
134  `;
135}
136
137/**
138 * Calculate the total fixed offset at the top of the viewport
139 * (sticky header + site alert banner if present).
140 * @returns {number} Pixel offset to subtract from scroll target
141 */
142function getFixedHeaderOffset() {
143  const header = document.querySelector('header');
144  const alert = document.querySelector('.site-alert');
145  let offset = 0;
146  if (header) offset += header.getBoundingClientRect().height;
147  if (alert) offset += alert.getBoundingClientRect().height;
148  return offset + 16; // 16px breathing room
149}
150
151/**
152 * Smooth-scroll an element into view, accounting for sticky header + alert banner.
153 * @param {HTMLElement} el - The element to scroll to
154 */
155function scrollToElement(el) {
156  const offset = getFixedHeaderOffset();
157  const top = el.getBoundingClientRect().top + window.scrollY - offset;
158  window.scrollTo({ top, behavior: 'smooth' });
159}
160
161function getChildIndexPath(section, target) {
162  const childIndexPath = [];
163  let currentElement = target;
164
165  while (currentElement && currentElement !== section) {
166    const { parentElement } = currentElement;
167    if (!parentElement) return null;
168
169    const childIndex = [...parentElement.children].indexOf(currentElement);
170    if (childIndex < 0) return null;
171
172    childIndexPath.unshift(childIndex);
173    currentElement = parentElement;
174  }
175
176  return currentElement === section ? childIndexPath : null;
177}
178
179function resolveChildIndexPath(section, childIndexPath) {
180  return childIndexPath.reduce(
181    (currentElement, childIndex) => currentElement?.children[childIndex] || null,
182    section,
183  );
184}
185
186function getStableDescendantFocusTarget(block, activeElement) {
187  const section = activeElement.closest(
188    STABLE_CONTENT_SECTION_CLASSES.map((className) => `.${className}`).join(', '),
189  );
190  if (!section || !block.contains(section)) return null;
191
192  const matchesSectionClass = (className) => section.classList.contains(className);
193  const sectionClass = STABLE_CONTENT_SECTION_CLASSES.find(matchesSectionClass);
194  const childIndexPath = getChildIndexPath(section, activeElement);
195  if (!sectionClass || !childIndexPath) return null;
196
197  return { type: 'descendant', sectionClass, childIndexPath };
198}
199
200function getInteractionState(block) {
201  const header = block.querySelector('.disclaimer-header');
202  const { activeElement } = document;
203  let focusTarget = null;
204
205  if (activeElement && block.contains(activeElement)) {
206    const returnLink = activeElement.closest('.disclaimer-return');
207
208    if (returnLink?.dataset.index) {
209      focusTarget = { type: 'return', value: returnLink.dataset.index };
210    } else if (activeElement.classList.contains('disclaimer-item') && activeElement.id) {
211      focusTarget = { type: 'item', value: activeElement.id };
212    } else if (activeElement.closest('.disclaimer-header')) {
213      focusTarget = { type: 'header' };
214    } else {
215      focusTarget = getStableDescendantFocusTarget(block, activeElement);
216    }
217  }
218
219  return {
220    expanded: header?.getAttribute('aria-expanded') === 'true',
221    focusTarget,
222    visibleReturnIndices: [...block.querySelectorAll('.disclaimer-return.is-visible')]
223      .map((returnLink) => returnLink.dataset.index)
224      .filter(Boolean),
225  };
226}
227
228function restoreInteractionState(block, interactionState) {
229  const header = block.querySelector('.disclaimer-header');
230  const content = block.querySelector('.disclaimer-content');
231
232  if (interactionState.expanded && content) {
233    header.setAttribute('aria-expanded', 'true');
234    content.removeAttribute('hidden');
235    content.classList.add('open');
236    content.style.maxHeight = 'none';
237  }
238
239  const visibleReturnIndices = new Set(interactionState.visibleReturnIndices);
240  block.querySelectorAll('.disclaimer-return').forEach((returnLink) => {
241    if (visibleReturnIndices.has(returnLink.dataset.index)) {
242      returnLink.classList.add('is-visible');
243    }
244  });
245
246  const { focusTarget } = interactionState;
247  if (!focusTarget) return;
248
249  let target = null;
250  if (focusTarget.type === 'header') {
251    target = header;
252  } else if (focusTarget.type === 'item') {
253    const disclaimerItem = document.getElementById(focusTarget.value);
254    if (disclaimerItem && block.contains(disclaimerItem)) target = disclaimerItem;
255  } else if (focusTarget.type === 'return') {
256    target = [...block.querySelectorAll('.disclaimer-return')].find(
257      (returnLink) => returnLink.dataset.index === focusTarget.value,
258    );
259  } else if (focusTarget.type === 'descendant') {
260    const { sectionClass } = focusTarget;
261    const matchesSectionClass = (element) => element.classList.contains(sectionClass);
262    const section = [...block.querySelectorAll('.disclaimer-content > .disclaimer-top-cf, .disclaimer-content > .disclaimer-top, .disclaimer-content > .disclaimer-auto, .disclaimer-content > .disclaimer-bottom-cf, .disclaimer-content > .disclaimer-bottom')].find(
263      matchesSectionClass,
264    );
265    if (section) target = resolveChildIndexPath(section, focusTarget.childIndexPath);
266  }
267
268  target?.focus({ preventScroll: true });
269}
270
271function removePendingHighlightWork(block, highlightWork) {
272  const pendingHighlightWork = pendingHighlightWorkByBlock.get(block);
273  if (!pendingHighlightWork) return;
274
275  pendingHighlightWork.delete(highlightWork);
276  if (pendingHighlightWork.size === 0) pendingHighlightWorkByBlock.delete(block);
277}
278
279function cancelPendingHighlightWork(block) {
280  const pendingHighlightWork = pendingHighlightWorkByBlock.get(block);
281  if (!pendingHighlightWork) return;
282
283  pendingHighlightWork.forEach((highlightWork) => {
284    highlightWork.cancelled = true;
285    highlightWork.observer.disconnect();
286    if (highlightWork.fallbackTimeoutId !== null) {
287      window.clearTimeout(highlightWork.fallbackTimeoutId);
288    }
289    if (highlightWork.highlightTimeoutId !== null) {
290      window.clearTimeout(highlightWork.highlightTimeoutId);
291    }
292    highlightWork.target.classList.remove('disclaimer-highlight');
293  });
294  pendingHighlightWorkByBlock.delete(block);
295}
296
297function scheduleHighlight(block, target) {
298  let pendingHighlightWork = pendingHighlightWorkByBlock.get(block);
299  if (!pendingHighlightWork) {
300    pendingHighlightWork = new Set();
301    pendingHighlightWorkByBlock.set(block, pendingHighlightWork);
302  }
303
304  const highlightWork = {
305    target,
306    observer: null,
307    fallbackTimeoutId: null,
308    highlightTimeoutId: null,
309    highlighted: false,
310    cancelled: false,
311  };
312  pendingHighlightWork.add(highlightWork);
313
314  const applyHighlight = () => {
315    if (highlightWork.cancelled || highlightWork.highlighted) return;
316    if (!block.contains(target)) {
317      removePendingHighlightWork(block, highlightWork);
318      return;
319    }
320
321    highlightWork.highlighted = true;
322    if (highlightWork.fallbackTimeoutId !== null) {
323      window.clearTimeout(highlightWork.fallbackTimeoutId);
324      highlightWork.fallbackTimeoutId = null;
325    }
326    target.classList.add('disclaimer-highlight');
327    highlightWork.highlightTimeoutId = window.setTimeout(() => {
328      target.classList.remove('disclaimer-highlight');
329      highlightWork.highlightTimeoutId = null;
330      removePendingHighlightWork(block, highlightWork);
331    }, 1500);
332  };
333
334  const observer = new IntersectionObserver(
335    (entries) => {
336      if (entries[0].isIntersecting) {
337        observer.disconnect();
338        applyHighlight();
339      }
340    },
341    { threshold: 0.5 },
342  );
343  highlightWork.observer = observer;
344  highlightWork.fallbackTimeoutId = window.setTimeout(() => {
345    highlightWork.fallbackTimeoutId = null;
346    observer.disconnect();
347    applyHighlight();
348  }, 2000);
349  observer.observe(target);
350}
351
352function teardownDisclaimer(block) {
353  cancelPendingHighlightWork(block);
354
355  const blockClickHandler = blockClickHandlers.get(block);
356  if (blockClickHandler) {
357    block.removeEventListener('click', blockClickHandler);
358    blockClickHandlers.delete(block);
359  }
360
361  const eventRoot = block.closest('main') || block.parentElement;
362  const triggerHandler = triggerHandlers.get(eventRoot);
363  if (triggerHandler) {
364    eventRoot.removeEventListener('click', triggerHandler);
365    triggerHandlers.delete(eventRoot);
366  }
367
368  const activeSourceLinks = activeSourceLinksByBlock.get(block);
369  activeSourceLinks?.clear();
370  activeSourceLinksByBlock.delete(block);
371
372  block.replaceChildren();
373  block.style.display = 'none';
374}
375
376/**
377 * Bind all disclaimer interaction event handlers.
378 * Uses event delegation on the block root where possible.
379 */
380function bindDisclaimerEventHandlers(block) {
381  const header = block.querySelector('.disclaimer-header');
382  const content = block.querySelector('.disclaimer-content');
383  const eventRoot = block.closest('main') || block.parentElement;
384  let activeSourceLinks = activeSourceLinksByBlock.get(block);
385  if (!activeSourceLinks) {
386    activeSourceLinks = new Map();
387    activeSourceLinksByBlock.set(block, activeSourceLinks);
388  }
389
390  const existingBlockClickHandler = blockClickHandlers.get(block);
391  if (existingBlockClickHandler) {
392    block.removeEventListener('click', existingBlockClickHandler);
393  }
394
395  const blockClickHandler = (event) => {
396    const returnLink = event.target.closest('.disclaimer-return');
397    if (returnLink) {
398      event.preventDefault();
399      const idx = returnLink.dataset.index;
400      returnLink.classList.remove('is-visible');
401      const sourceLink = activeSourceLinks.get(idx);
402      if (sourceLink) {
403        scrollToElement(sourceLink);
404        sourceLink.focus({ preventScroll: true });
405        activeSourceLinks.delete(idx);
406      }
407      return;
408    }
409
410    // Only toggle when clicking the header button itself
411    if (event.target.closest('.disclaimer-header')) {
412      togglePanel(header, content);
413    }
414  };
415  block.addEventListener('click', blockClickHandler);
416  blockClickHandlers.set(block, blockClickHandler);
417
418  const existingTriggerHandler = triggerHandlers.get(eventRoot);
419  if (existingTriggerHandler) {
420    eventRoot.removeEventListener('click', existingTriggerHandler);
421  }
422
423  // Superscript triggers live outside the block, so delegate from the nearest page-content root.
424  const triggerHandler = (event) => {
425    const link = event.target.closest('.disclaimer-link');
426    if (!link || !eventRoot.contains(link)) return;
427
428    event.preventDefault();
429    const targetId = link.dataset.target || link.getAttribute('href')?.replace('#', '');
430    if (!targetId) return;
431    const targetEl = document.getElementById(targetId);
432
433    // Store which specific link was clicked so Return goes back to it
434    const idx = targetId.replace('disclaimer-', '');
435    activeSourceLinks.set(idx, link);
436
437    // Open accordion if closed
438    if (header.getAttribute('aria-expanded') === 'false') {
439      togglePanel(header, content);
440    }
441
442    // Scroll, focus, and highlight — delay until element is visible
443    if (targetEl) {
444      // Show the return link for this disclaimer item
445      const returnLink = targetEl.querySelector('.disclaimer-return');
446      if (returnLink) returnLink.classList.add('is-visible');
447
448      scrollToElement(targetEl);
449      targetEl.focus({ preventScroll: true });
450      scheduleHighlight(block, targetEl);
451    }
452  };
453
454  eventRoot.addEventListener('click', triggerHandler);
455  triggerHandlers.set(eventRoot, triggerHandler);
456}
457
458export function getDisclaimerBlockData(block) {
459  return blockDataByElement.get(block) || null;
460}
461
462export default async function decorate(block, response = null, options = {}) {
463  const { siteContentRoot = DEFAULT_SITE_CONTENT_ROOT } = options;
464
465  const data = extractData(block);
466
467  const universalEditorMode = isUniversalEditorMode();
468  if (universalEditorMode && !response) return;
469  const resolvedResponse = response || { top: [], disclaimers: [], bottom: [] };
470
471  const hasContent = [
472    resolvedResponse.top.some(({ html }) => html),
473    resolvedResponse.disclaimers.length > 0,
474    resolvedResponse.bottom.some(({ html }) => html),
475    Boolean(data.topContent),
476    Boolean(data.bottomContent),
477  ].some(Boolean);
478  if (!hasContent) {
479    teardownDisclaimer(block);
480    return;
481  }
482
483  const interactionState = getInteractionState(block);
484  cancelPendingHighlightWork(block);
485  block.style.display = '';
486  block.innerHTML = renderHTML({ ...data, response: resolvedResponse });
487
488  const servletSections = block.querySelectorAll(
489    '.disclaimer-top-cf, .disclaimer-auto, .disclaimer-bottom-cf',
490  );
491  servletSections.forEach((section) => {
492    const links = section.querySelectorAll(
493      `a[href="${siteContentRoot}"], a[href^="${siteContentRoot}/"]`,
494    );
495    links.forEach((link) => {
496      const href = link.getAttribute('href');
497      link.setAttribute('href', href.slice(siteContentRoot.length) || '/');
498    });
499  });
500
501  // Bind interactions
502  bindDisclaimerEventHandlers(block);
503  restoreInteractionState(block, interactionState);
504}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.