DETAIL TEXT
+This Hero has all row types
+lockup, list, qrcode, text, background
+ +diff --git a/libs/blocks/action-scroller/action-scroller.js b/libs/blocks/action-scroller/action-scroller.js index dd0a3af5056..51add7da6c0 100644 --- a/libs/blocks/action-scroller/action-scroller.js +++ b/libs/blocks/action-scroller/action-scroller.js @@ -119,6 +119,7 @@ export default function init(el) { el.replaceChildren(items, ...buttons); if (hasNav) { handleBtnState(items, buttons); + if (!items.querySelectorAll('a').length) items.setAttribute('tabindex', 0); allActionScrollers.push({ scroller: items, buttons }); import('../../utils/action.js').then(({ debounce }) => { items.addEventListener('scroll', debounce(() => handleBtnState(items, buttons), 50)); diff --git a/libs/blocks/graybox/graybox.css b/libs/blocks/graybox/graybox.css index 72e250b0df1..996d6fe560e 100644 --- a/libs/blocks/graybox/graybox.css +++ b/libs/blocks/graybox/graybox.css @@ -1,3 +1,4 @@ +/* stylelint-disable selector-class-pattern */ :root { --base-z-index: 100; --gb-container-bg: white; @@ -80,7 +81,7 @@ background-color: var(--gb-overlay-color); content: ""; display: block; - height: 100%; + height: 100vh; left: 0; pointer-events: none; position: fixed; @@ -105,7 +106,7 @@ /* The elements that should appear above the overlay */ .gb-graybox-body .gb-changed { position: relative; - z-index: calc(var(--base-z-index) - 1); + z-index: calc(var(--base-z-index) + 1); } .graybox-container { @@ -322,3 +323,107 @@ border-radius: 5px; } } + +/* Spectrum Switch */ +.spectrum-Switch { + display: inline-flex; + align-items: flex-start; + position: relative; + margin: auto; + min-block-size: var(--mod-switch-height, var(--spectrum-switch-min-height)); + max-inline-size: 100%; + vertical-align: top; +} + +.spectrum-Switch.gb-toggle-disabled { + pointer-events: none; + opacity: 0.5; +} + +/* .gb-toggle-disabled .spectrum-Switch { + pointer-events: none; +} */ + +.spectrum-Switch-input { + margin: 0; + box-sizing: border-box; + padding: 0; + position: absolute; + inline-size: 100%; + block-size: 100%; + inset-block-start: 0; + inset-inline-start: 0; + opacity: 0; + z-index: 1; + cursor: pointer; +} + +.spectrum-Switch-switch { + display: inline-block; + box-sizing: border-box; + position: relative; + inline-size: 26px; + margin-block: 9px; + margin-inline: 0; + flex-grow: 0; + flex-shrink: 0; + vertical-align: middle; + transition: background 0.16s ease-in-out, border 0.16s ease-in-out; + block-size: 14px; + inset-inline-start: 0; + inset-inline-end: 0; + border-radius: 7px; + background-color: rgb(225 225 225); +} + +.spectrum-Switch-switch::after, .spectrum-Switch-switch::before { + display: block; + position: absolute; + content: ""; + inset-block-start: 0; + inset-inline-start: 0; +} + +.spectrum-Switch-switch::before { + box-sizing: border-box; + transition: background 0.16s ease-in-out, border 0.16s ease-in-out, transform 0.13s ease-in-out, box-shadow 0.16s ease-in-out; + inline-size: 14px; + block-size: 14px; + border-width: 2px; + border-radius: 7px; + border-style: solid; + background-color: rgb(248 248 248); + border-color: rgb(113 113 113); +} + +.spectrum-Switch-switch::after { + border-radius: 11px; + inset-inline-end: 0; + inset-block-end: 0; + margin: 0; + transition: opacity 0.16s ease-out, margin 0.16s ease-out; +} + +.spectrum-Switch-label { + color: rgb(41 41 41); + margin-inline: 10px; + margin-block-start: 6px; + margin-block-end: 0; + font-size: 14px; + line-height: 1.3; + transition: color 0.16s ease-in-out; +} + +.spectrum-Switch-input:checked+.spectrum-Switch-switch::before { + transform: translate(calc(26px - 100%)); + border-color: rgb(80 80 80); +} + +.spectrum-Switch:hover .spectrum-Switch-input+.spectrum-Switch-switch::before { + border-color: rgb(80 80 80); + box-shadow: none; +} + +.spectrum-Switch-input:checked+.spectrum-Switch-switch { + background-color: rgb(41 41 41); +} diff --git a/libs/blocks/graybox/graybox.js b/libs/blocks/graybox/graybox.js index 3e03d6ca37f..cfced9e67f2 100644 --- a/libs/blocks/graybox/graybox.js +++ b/libs/blocks/graybox/graybox.js @@ -19,12 +19,20 @@ const CLASS = { NO_BORDER: 'gb-no-border', NO_CHANGE: 'gb-no-change', NO_CLICK: 'gb-no-click', + OVERLAY_OFF: 'gb-overlay-off', PAGE_OVERLAY: 'gb-page-overlay', PHONE_PREVIEW: 'gb-phone-preview', TABLET_PREVIEW: 'gb-tablet-preview', SELECTED_BUTTON: 'gb-selected-button', }; +const DISABLED = { + CHANGED: 'gb-disabled-changed', + NO_CHANGE: 'gb-disabled-no-change', + NO_CLICK: 'gb-disabled-no-click', + PAGE_OVERLAY: 'gb-disabled-page-overlay', +}; + const METADATA = { DESC: 'gb-desc', FOOTER: 'gb-footer', @@ -40,6 +48,7 @@ const USER_AGENT = { const DEFAULT_TITLE = ''; let deviceModal; +let deviceIframe; const setMetadata = (metadata) => { const { selector, val } = metadata; @@ -168,12 +177,35 @@ function setUserAgent(window, userAgent) { // eslint-disable-next-line no-promise-executor-return const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)); -const injectCSSIntoIframe = (iframe, cssRules) => { - iframe.addEventListener('load', () => { +const toggleGrayboxOverlay = (isChecked) => { + if (!isChecked) { + document.body.classList.add(CLASS.OVERLAY_OFF); + } else { + document.body.classList.remove(CLASS.OVERLAY_OFF); + } + + const toggleMap = [ + [CLASS.CHANGED, DISABLED.CHANGED], + [CLASS.NO_CHANGE, DISABLED.NO_CHANGE], + [CLASS.NO_CLICK, DISABLED.NO_CLICK], + [CLASS.PAGE_OVERLAY, DISABLED.PAGE_OVERLAY], + ]; + + toggleMap.forEach(([enabled, disabled]) => { + const from = isChecked ? disabled : enabled; + const to = isChecked ? enabled : disabled; + document.querySelectorAll(`.${from}`).forEach((el) => { + el.classList.replace(from, to); + }); + }); +}; + +const injectCSSIntoIframe = (cssRules) => { + deviceIframe.addEventListener('load', () => { try { const style = document.createElement('style'); style.textContent = cssRules; - iframe.contentWindow.document.head.appendChild(style); + deviceIframe.contentWindow.document.head.appendChild(style); } catch (error) { // eslint-disable-next-line no-console console.warn('Could not inject CSS into iframe. It might be cross-origin.', error); @@ -181,32 +213,32 @@ const injectCSSIntoIframe = (iframe, cssRules) => { }); }; -const setIframeUA = (iFrame, isMobile, isTablet) => { +const setIframeUA = (isMobile, isTablet) => { if (isMobile) { document.body.classList.add(CLASS.PHONE_PREVIEW); deviceModal.classList.add('mobile'); - setUserAgent(iFrame.contentWindow, USER_AGENT.iPhone); + setUserAgent(deviceIframe.contentWindow, USER_AGENT.iPhone); } else if (isTablet) { document.body.classList.add(CLASS.TABLET_PREVIEW); deviceModal.classList.add('tablet'); - setUserAgent(iFrame.contentWindow, USER_AGENT.iPad); + setUserAgent(deviceIframe.contentWindow, USER_AGENT.iPad); } }; -const setupIframe = (iFrame, isMobile, isTablet) => { +const setupIframe = (isMobile, isTablet) => { const cssRules = ` body > .mep-preview-overlay { display: none !important; } `; - injectCSSIntoIframe(iFrame, cssRules); + injectCSSIntoIframe(cssRules); - iFrame.style.height = ''; + deviceIframe.style.height = ''; - setIframeUA(iFrame, isMobile, isTablet); + setIframeUA(isMobile, isTablet); // Spoof iFrame dimensions as screen size for MEP - iFrame.contentWindow.screen = { + deviceIframe.contentWindow.screen = { width: isMobile ? 350 : 768, height: isMobile ? 800 : 1024, }; @@ -239,10 +271,15 @@ const openDeviceModal = async (e) => { const docFrag = new DocumentFragment(); const iFrameUrl = new URL(window.location.href); iFrameUrl.searchParams.set('graybox', 'menu-off'); + if (document.body.classList.contains(CLASS.OVERLAY_OFF)) { + iFrameUrl.searchParams.set('graybox-overlay', 'off'); + } else { + iFrameUrl.searchParams.delete('graybox-overlay'); + } const deviceBorder = createTag('img', { class: 'graybox-device-border', src: isMobile ? iphoneFrame : ipadFrame }); - const iFrame = createTag('iframe', { src: iFrameUrl.href, width: '100%', height: '100%' }); + deviceIframe = createTag('iframe', { src: iFrameUrl.href, width: '100%', height: '100%' }); - const modal = createTag('div', null, [deviceBorder, iFrame]); + const modal = createTag('div', null, [deviceBorder, deviceIframe]); docFrag.append(modal); deviceModal = await getModal( @@ -253,14 +290,14 @@ const openDeviceModal = async (e) => { const isSafari = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent); if (isSafari) { deviceModal.style.display = 'none'; - setupIframe(iFrame, isMobile, isTablet); - iFrame.addEventListener('load', () => { + setupIframe(isMobile, isTablet); + deviceIframe.addEventListener('load', () => { // safari needs to wait for the iframe to load before setting the user agent - setIframeUA(iFrame, isMobile, isTablet); + setIframeUA(isMobile, isTablet); deviceModal.style.display = ''; }); } else { - setupIframe(iFrame, isMobile, isTablet); + setupIframe(isMobile, isTablet); } const onDeviceModalClose = () => { @@ -275,6 +312,8 @@ const openDeviceModal = async (e) => { CLASS.PHONE_PREVIEW, CLASS.TABLET_PREVIEW, ); + + deviceIframe = null; }; window.addEventListener('milo:modal:closed', onDeviceModalClose, { once: true }); @@ -283,6 +322,30 @@ const openDeviceModal = async (e) => { curtain.classList.add('graybox-curtain'); }; +const createGrayboxOverlayToggle = (grayboxMenu) => { + const switchDiv = createTag('div', { class: 'spectrum-Switch' }, null, { parent: grayboxMenu }); + const input = createTag('input', { type: 'checkbox', class: 'spectrum-Switch-input', id: 'gb-overlay-toggle' }, null, { parent: switchDiv }); + createTag('span', { class: 'spectrum-Switch-switch' }, null, { parent: switchDiv }); + createTag('label', { class: 'spectrum-Switch-label', for: 'gb-overlay-toggle' }, 'Graybox Overlay', { parent: switchDiv }); + input.checked = true; + input.addEventListener('change', (e) => { + const isChecked = e.target.checked; + toggleGrayboxOverlay(isChecked); + if (deviceIframe) { + deviceIframe.contentWindow.postMessage(`gb-overlay-${isChecked ? 'on' : 'off'}`, '*'); + } + }); + + if (!document.querySelector(`.${CLASS.CHANGED}, .${CLASS.NO_CHANGE}, .${CLASS.NO_CLICK}, .${CLASS.PAGE_OVERLAY}`)) { + switchDiv.classList.add('gb-toggle-disabled'); + } + + const url = new URL(window.location.href); + if (url.searchParams.get('graybox-overlay') === 'off') { + setTimeout(() => input.click(), 10); + } +}; + const createGrayboxMenu = (options, { isOpen = false } = {}) => { const grayboxContainer = createTag('div', { class: 'graybox-container' }); const grayboxMenu = createTag('div', { class: 'graybox-menu' }, null, { parent: grayboxContainer }); @@ -306,6 +369,8 @@ const createGrayboxMenu = (options, { isOpen = false } = {}) => { button.addEventListener('click', openDeviceModal); }); + createGrayboxOverlayToggle(grayboxMenu); + const toggleBtn = document.createElement('button'); toggleBtn.className = 'gb-toggle'; toggleBtn.addEventListener('click', () => { @@ -322,7 +387,10 @@ const createGrayboxMenu = (options, { isOpen = false } = {}) => { const addPageOverlayDiv = () => { const overlayDiv = createTag('div', { class: CLASS.PAGE_OVERLAY }); - document.body.insertBefore(overlayDiv, document.body.firstChild); + const main = document.querySelector('main'); + if (main) { + main.insertBefore(overlayDiv, main.firstChild); + } }; const setupChangedEls = (globalNoClick) => { @@ -420,6 +488,10 @@ const grayboxThePage = (grayboxEl, grayboxMenuOff) => { /* c8 ignore next 3 */ if (grayboxMenuOff) { document.body.classList.add(CLASS.NO_BORDER); + const url = new URL(window.location.href); + if (url.searchParams.get('graybox-overlay') === 'off') { + toggleGrayboxOverlay(false); + } } else { createGrayboxMenu(options, { isOpen: true }); } @@ -440,6 +512,15 @@ const grayboxThePage = (grayboxEl, grayboxMenuOff) => { childList: true, subtree: true, }); + + // Listen for iframe messages to toggle graybox overlay in device views + window.addEventListener('message', (event) => { + if (event.data === 'gb-overlay-on') { + toggleGrayboxOverlay(true); + } else if (event.data === 'gb-overlay-off') { + toggleGrayboxOverlay(false); + } + }); }; export default function init(grayboxEl) { @@ -461,5 +542,8 @@ export default function init(grayboxEl) { setMetadata({ selector: 'georouting', val: 'off' }); const grayboxMenuOff = url.searchParams.get('graybox') === 'menu-off'; - window.milo.deferredPromise.then(() => grayboxThePage(grayboxEl, grayboxMenuOff)); + window.milo.deferredPromise.then(() => grayboxThePage( + grayboxEl, + grayboxMenuOff, + )); } diff --git a/libs/blocks/hero-marquee/hero-marquee.css b/libs/blocks/hero-marquee/hero-marquee.css index d9095227b35..d4e84e061b1 100644 --- a/libs/blocks/hero-marquee/hero-marquee.css +++ b/libs/blocks/hero-marquee/hero-marquee.css @@ -379,12 +379,6 @@ html[dir="rtl"] .hero-marquee li.icon-item span.icon { /* min height */ .hero-marquee.s-min-height-tablet { min-height: var(--s-min-height);} .hero-marquee.l-min-height-tablet { min-height: var(--l-min-height);} - - /* helper classes */ - .hero-marquee .order-0-tablet { order: 0; } - .hero-marquee .order-1-tablet { order: 1; } - .hero-marquee .order-2-tablet { order: 2; } - .hero-marquee .order-3-tablet { order: 3; } } @media screen and (min-width: 920px) { @@ -474,10 +468,4 @@ html[dir="rtl"] .hero-marquee li.icon-item span.icon { /* min height */ .hero-marquee.s-min-height-desktop { min-height: var(--s-min-height);} .hero-marquee.l-min-height-desktop { min-height: var(--l-min-height);} - - /* helper classes */ - .hero-marquee .order-0-desktop { order: 0; } - .hero-marquee .order-1-desktop { order: 1; } - .hero-marquee .order-2-desktop { order: 2; } - .hero-marquee .order-3-desktop { order: 3; } } diff --git a/libs/blocks/hero-marquee/hero-marquee.js b/libs/blocks/hero-marquee/hero-marquee.js index f747559e297..1cac8cf4277 100644 --- a/libs/blocks/hero-marquee/hero-marquee.js +++ b/libs/blocks/hero-marquee/hero-marquee.js @@ -166,6 +166,66 @@ function loadBreakpointThemes() { loadStyle(`${base}/styles/breakpoint-theme.css`); } +export function getViewportOrder(viewport, content) { + const els = [...content.children]; + const viewportObject = { 0: [] }; + els.forEach((el) => { + const orderClass = { + tablet: null, + desktop: null, + }; + el.classList.forEach((className) => { + if (!className.startsWith('order-') + || (!className.endsWith('desktop') && !className.endsWith('tablet'))) return; + orderClass.tablet = orderClass.tablet || (className.endsWith('tablet') ? className : null); + orderClass.desktop = orderClass.desktop || (className.endsWith('desktop') ? className : null); + }); + const viewportClass = orderClass[viewport] || orderClass.tablet; + const order = parseInt(viewportClass?.split('-')[1], 10); + if (Number.isInteger(order)) { + if (!viewportObject[order]) viewportObject[order] = []; + viewportObject[order].push(el); + } else { + viewportObject[0].push(el); + } + }); + + const viewportOrder = []; + Object.keys(viewportObject).sort((a, b) => a - b).forEach((key) => { + viewportOrder.push(...viewportObject[key]); + }); + return viewportOrder; +} + +function handleViewportOrder(content) { + const hasOrder = content.querySelector(':scope > div[class*="order-"]'); + if (!hasOrder) return; + + const viewports = { + mobile: { + media: '(max-width: 599px)', + elements: [...content.children], + }, + tablet: { + media: '(min-width: 600px) and (max-width: 1199px)', + elements: getViewportOrder('tablet', content), + }, + desktop: { + media: '(min-width: 1200px)', + elements: getViewportOrder('desktop', content), + }, + }; + + Object.entries(viewports).forEach(([viewport, { media, elements }]) => { + const mediaQuery = window.matchMedia(media); + if (mediaQuery.matches && viewport !== 'mobile') content.replaceChildren(...elements); + mediaQuery.addEventListener('change', (e) => { + if (!e.matches) return; + content.replaceChildren(...elements); + }); + }); +} + export default async function init(el) { el.classList.add('con-block'); let rows = el.querySelectorAll(':scope > div'); @@ -268,6 +328,7 @@ export default async function init(el) { } }); decorateTextOverrides(el, ['-heading', '-body', '-detail'], mainCopy); + handleViewportOrder(copy); if (el.classList.contains('countdown-timer')) { promiseArr.push(loadCDT(copy, el.classList)); diff --git a/libs/blocks/merch-card-autoblock/merch-card-autoblock.js b/libs/blocks/merch-card-autoblock/merch-card-autoblock.js index 905217ebd39..c1678b088bf 100644 --- a/libs/blocks/merch-card-autoblock/merch-card-autoblock.js +++ b/libs/blocks/merch-card-autoblock/merch-card-autoblock.js @@ -2,6 +2,7 @@ import { createTag } from '../../utils/utils.js'; import '../../deps/mas/merch-card.js'; import '../../deps/mas/merch-quantity-select.js'; import { initService, getOptions, overrideOptions } from '../merch/merch.js'; +import { postProcessAutoblock } from '../merch/autoblock.js'; const CARD_AUTOBLOCK_TIMEOUT = 5000; let log; @@ -38,6 +39,7 @@ export async function createCard(el, options) { const merchCard = createTag('merch-card', { consonant: '' }, aemFragment); el.replaceWith(merchCard); await checkReady(merchCard); + postProcessAutoblock(merchCard); } export default async function init(el) { diff --git a/libs/blocks/merch-card-collection-autoblock/merch-card-collection-autoblock.js b/libs/blocks/merch-card-collection-autoblock/merch-card-collection-autoblock.js index 1b241eedd8b..f58d675dbe9 100644 --- a/libs/blocks/merch-card-collection-autoblock/merch-card-collection-autoblock.js +++ b/libs/blocks/merch-card-collection-autoblock/merch-card-collection-autoblock.js @@ -1,5 +1,6 @@ import { createTag, getConfig } from '../../utils/utils.js'; import { initService, getOptions, MEP_SELECTOR, overrideOptions } from '../merch/merch.js'; +import { postProcessAutoblock } from '../merch/autoblock.js'; import '../../deps/mas/merch-card.js'; import '../../deps/mas/merch-quantity-select.js'; @@ -154,6 +155,7 @@ export async function createCollection(el, options) { } } + postProcessAutoblock(collection); collection.requestUpdate(); } diff --git a/libs/blocks/merch-card/merch-card.js b/libs/blocks/merch-card/merch-card.js index f94fbce622a..1c5c23a2531 100644 --- a/libs/blocks/merch-card/merch-card.js +++ b/libs/blocks/merch-card/merch-card.js @@ -458,7 +458,7 @@ const createFirstRow = async (firstRow, isMobile, checkmarkCopyContainer, defaul 'aria-expanded': defaultChevronState === 'open', 'aria-controls': checkmarkCopyContainer.id, 'daa-lh': `${checkmarkCopyContainer.id}-toggle-button`, - }, addIcon); + }, defaultChevronState === 'open' ? removeIcon : addIcon); firstRowTextParagraph = createTag('div', { class: 'footer-rows-title' }, firstRowText); firstRowTextParagraph.appendChild(toggleIcon); diff --git a/libs/blocks/merch/autoblock.js b/libs/blocks/merch/autoblock.js new file mode 100644 index 00000000000..dae75f541e5 --- /dev/null +++ b/libs/blocks/merch/autoblock.js @@ -0,0 +1,22 @@ +import { decorateLinks, loadBlock, localizeLink } from '../../utils/utils.js'; + +export function localizePreviewLinks(el) { + const anchors = el.getElementsByTagName('a'); + for (const a of anchors) { + const { href } = a; + if (href?.match(/http[s]?:\/\/\S*\.(hlx|aem).(page|live)\//)) { + try { + const url = new URL(href); + a.href = localizeLink(href, url.hostname); + } catch (e) { + window.lana?.log(`Invalid URL - ${href}: ${e.toString()}`); + } + } + } +} + +export function postProcessAutoblock(autoblockEl) { + decorateLinks(autoblockEl); + localizePreviewLinks(autoblockEl); + autoblockEl.querySelectorAll('.modal.link-block').forEach((blockEl) => loadBlock(blockEl)); +} diff --git a/libs/blocks/mmm/mmm.js b/libs/blocks/mmm/mmm.js index e46a53f6f2c..f79b8a38f5f 100644 --- a/libs/blocks/mmm/mmm.js +++ b/libs/blocks/mmm/mmm.js @@ -819,7 +819,8 @@ async function createView(el, search) { switch (true) { case isReport: url = API_URLS.report; break; case isMetadataLookup: { - url = TARGET_METADATA_OPTIONS[SEARCH().selectedRepo].metadata; + const rngString = Math.random().toString(36).substring(2, 10); + url = `${TARGET_METADATA_OPTIONS[SEARCH().selectedRepo].metadata}?limit=10000&r=${rngString}`; method = 'GET'; body = null; break; diff --git a/libs/utils/utils.js b/libs/utils/utils.js index 471f9c26d0f..a637db67a6c 100644 --- a/libs/utils/utils.js +++ b/libs/utils/utils.js @@ -992,9 +992,9 @@ export function decorateLinks(el) { const pipeRegex = /\s?\|([^|]*)$/; if (pipeRegex.test(a.textContent) && !/\.[a-z]+/i.test(a.textContent)) { const node = [...a.childNodes].reverse()[0]; - const ariaLabel = node.textContent.match(pipeRegex)[1]; + const ariaLabel = node.textContent.match(pipeRegex)?.[1]; node.textContent = node.textContent.replace(pipeRegex, ''); - a.setAttribute('aria-label', ariaLabel.trim()); + a.setAttribute('aria-label', (ariaLabel || '').trim()); } return rdx; diff --git a/nala/blocks/mnemonic/mnemonic.page.js b/nala/blocks/mnemonic/mnemonic.page.js new file mode 100644 index 00000000000..5aa529a4a07 --- /dev/null +++ b/nala/blocks/mnemonic/mnemonic.page.js @@ -0,0 +1,9 @@ +export default class Mnemonic { + constructor(page) { + this.page = page; + + this.mnemonicList = page.locator('.mnemonic-list'); + this.productItems = page.locator('.product-item'); + this.productItemsImg = page.locator('.product-item img'); + } +} diff --git a/nala/blocks/mnemonic/mnemonic.spec.js b/nala/blocks/mnemonic/mnemonic.spec.js new file mode 100644 index 00000000000..b35adf52e1b --- /dev/null +++ b/nala/blocks/mnemonic/mnemonic.spec.js @@ -0,0 +1,16 @@ +module.exports = { + name: 'Mnemonic List', + features: [ + { + tcid: '0', + name: 'Mnemonic', + path: '/drafts/nala/blocks/mnemonic/mnemonic-list', + tags: '@mnemonic @smoke @regression @milo', + data: { + trackingHeader1: 'Includes:', + trackingHeader2: 'Acrobat', + trackingHeader3: 'Illustrator', + }, + }, + ], +}; diff --git a/nala/blocks/mnemonic/mnemonic.test.js b/nala/blocks/mnemonic/mnemonic.test.js new file mode 100644 index 00000000000..13696025cc2 --- /dev/null +++ b/nala/blocks/mnemonic/mnemonic.test.js @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import Mnemonic from './mnemonic.page.js'; +import { features } from './mnemonic.spec.js'; +import { runAccessibilityTest } from '../../libs/accessibility.js'; + +let mnemonic; + +const miloLibs = process.env.MILO_LIBS || ''; + +test.describe('Milo Mnemonic List test suite', () => { + test.beforeEach(async ({ page }) => { + mnemonic = new Mnemonic(page); + }); + + test(`[Test Id - ${features[0].tcid}] ${features[0].name},${features[0].tags}`, async ({ page, baseURL }) => { + console.info(`[Test Page]: ${baseURL}${features[0].path}${miloLibs}`); + const { data } = features[0]; + + await test.step('step-1: Go to Mnemonic List page', async () => { + await page.goto(`${baseURL}${features[0].path}${miloLibs}`); + await page.waitForLoadState('domcontentloaded'); + await expect(page).toHaveURL(`${baseURL}${features[0].path}${miloLibs}`); + }); + + await test.step('step-2: Verify Mnemonic List specs', async () => { + await expect(mnemonic.mnemonicList).toBeVisible(); + + await expect(mnemonic.productItems.nth(0)).toContainText(data.trackingHeader1); + await expect(mnemonic.productItems.nth(1)).toContainText(data.trackingHeader2); + await expect(mnemonic.productItemsImg.nth(0)).toHaveAttribute('src', '/assets/img/acrobat-pro.svg'); + await expect(mnemonic.productItems.nth(2)).toContainText(data.trackingHeader3); + await expect(mnemonic.productItemsImg.nth(1)).toHaveAttribute('src', '/assets/img/illustrator.svg'); + }); + + await test.step('step-3: Verify analytic attributes', async () => { + await expect(mnemonic.mnemonicList).toHaveAttribute('daa-lh', 'b1|mnemonic-list'); + }); + + await test.step('step-4: Verify the accessibility test on the Mnemonic List', async () => { + await runAccessibilityTest({ page, testScope: mnemonic.mnemonicList }); + }); + }); +}); diff --git a/nala/blocks/reading-time/reading-time.page.js b/nala/blocks/reading-time/reading-time.page.js new file mode 100644 index 00000000000..93505730673 --- /dev/null +++ b/nala/blocks/reading-time/reading-time.page.js @@ -0,0 +1,45 @@ +export default class ReadingTimeBlock { + constructor(page) { + this.page = page; + + this.readingTime = this.page.locator('.reading-time.content'); + this.media = this.page.locator('.media'); + this.foregroundImage = this.page.locator('.container.foreground .media-row .image'); + this.image = this.page.locator('.media-row .image img'); + this.marquee = this.page.locator('.marquee.small.light'); + + this.detailM = this.page.locator('.detail-m'); + this.headingXL = this.page.locator('.heading-xl'); + this.bodyM = this.page.locator('.body-m'); + this.outlineButtonL = this.page.locator('.con-button.outline.button-l'); + this.blueButtonL = this.page.locator('.con-button.blue.button-l'); + this.assetImage = this.page.locator('.asset.image img'); + + this.attributes = { + 'foreground.image': { + foregroundImg: { + loading: 'eager', + fetchpriority: 'high', + width: '400', + height: '300', + }, + }, + 'asset.image': { + assetImg: { + loading: 'eager', + fetchpriority: 'high', + width: '600', + height: '300', + }, + }, + }; + } + + async getReadingText() { + return this.readingTime.innerText(); + } + + async getContentText() { + return this.page.locator('body').innerText(); + } +} diff --git a/nala/blocks/reading-time/reading-time.spec.js b/nala/blocks/reading-time/reading-time.spec.js new file mode 100644 index 00000000000..f3816de3635 --- /dev/null +++ b/nala/blocks/reading-time/reading-time.spec.js @@ -0,0 +1,34 @@ +module.exports = { + name: 'Reading-time Block', + features: [ + { + tcid: '0', + name: 'Reading-time block', + path: '/drafts/nala/blocks/reading-time/reading-time-block', + data: { timeText: '0 min read' }, + tags: 'reading-time @smoke @regression @milo', + }, + { + tcid: '1', + name: 'Reading-time without text', + path: '/drafts/nala/blocks/reading-time/reading-time-withouttext', + tags: 'reading-time @smoke @regression @milo', + }, + { + tcid: '2', + name: 'Reading-time with text', + path: '/drafts/nala/blocks/reading-time/reading-time-text', + data: { + detailText: 'Detail', + h2Text: 'Heading XL Marquee standard small light', + h3Text: 'Text (inset, large, m spacing)', + bodyText: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.', + bodyTextL: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis ut nisl vitae urna volutpat tincidunt ac in mauris. In hac habitasse platea dictumst. Integer vel tempus nisi.Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation. ', + listItemsText: 'Lorem ipsum dolor sit amet.', + outlineButtonText: 'Lorem ipsum', + blueButtonText: 'Call to action', + }, + tags: 'reading-time @smoke @regression @milo', + }, + ], +}; diff --git a/nala/blocks/reading-time/reading-time.test.js b/nala/blocks/reading-time/reading-time.test.js new file mode 100644 index 00000000000..0b438146472 --- /dev/null +++ b/nala/blocks/reading-time/reading-time.test.js @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test'; +import WebUtil from '../../libs/webutil.js'; +import { features } from './reading-time.spec.js'; +import ReadingTimeBlock from './reading-time.page.js'; +import { runAccessibilityTest } from '../../libs/accessibility.js'; + +let readingTime; +let webUtil; + +const miloLibs = process.env.MILO_LIBS || ''; + +test.describe('Reading-time feature test suite', () => { + test.beforeEach(async ({ page }) => { + readingTime = new ReadingTimeBlock(page); + webUtil = new WebUtil(page); + }); + + test(`[Test Id - ${features[0].tcid}] ${features[0].name},${features[0].tags}`, async ({ page, baseURL }) => { + console.info(`[Test Page]: ${baseURL}${features[0].path}${miloLibs}`); + const { data } = features[0]; + await test.step('step-1: Go to Reading-time feature test page', async () => { + await page.goto(`${baseURL}${features[0].path}${miloLibs}`); + await page.waitForLoadState('domcontentloaded'); + await expect(page).toHaveURL(`${baseURL}${features[0].path}${miloLibs}`); + }); + + await test.step('step-2: Verify Reading-time specs', async () => { + await expect(await readingTime.readingTime).toBeVisible(); + await expect(await readingTime.readingTime).toContainText(data.timeText); + }); + + await test.step('step-3: Verify analytics attributes', async () => { + await expect(readingTime.readingTime).toHaveAttribute('daa-lh', 'b1|reading-time'); + }); + + await test.step('step-4: Verify the accessibility test on Reading-time block', async () => { + await runAccessibilityTest({ page, testScope: readingTime.readingTime, skipA11yTest: true }); + }); + }); + + test(`[Test Id - ${features[1].tcid}] ${features[1].name},${features[1].tags}`, async ({ page, baseURL }) => { + console.info(`[Test Page]: ${baseURL}${features[1].path}${miloLibs}`); + await test.step('step-1: Go to Reading-time without text test page', async () => { + await page.goto(`${baseURL}${features[1].path}${miloLibs}`); + await page.waitForLoadState('domcontentloaded'); + await expect(page).toHaveURL(`${baseURL}${features[1].path}${miloLibs}`); + }); + + await test.step('step-2: Verify Reading-time without text specs', async () => { + await expect(await readingTime.readingTime).toBeVisible(); + const actual = parseInt((await readingTime.getReadingText()).split(' ')[0], 10); + const contentText = await readingTime.getContentText(); + const wordCount = contentText.trim().split(/\s+/).length; + const expected = Math.ceil(wordCount / 200); + expect(Math.abs(actual - expected)).toBeLessThanOrEqual(1); + await expect(await readingTime.media).toBeVisible(); + await expect(await readingTime.foregroundImage).toBeVisible(); + }); + + await test.step('step-3: Verify analytics attributes', async () => { + await expect(readingTime.readingTime).toHaveAttribute('daa-lh', 'b1|reading-time'); + expect(await webUtil.verifyAttributes(readingTime.image, readingTime.attributes['foreground.image'].foregroundImg)).toBeTruthy(); + }); + + await test.step('step-4: Verify the accessibility test on Reading-time without text block', async () => { + await runAccessibilityTest({ page, testScope: readingTime.readingTime, skipA11yTest: true }); + }); + }); + + test(`[Test Id - ${features[2].tcid}] ${features[2].name},${features[2].tags}`, async ({ page, baseURL }) => { + console.info(`[Test Page]: ${baseURL}${features[2].path}${miloLibs}`); + const { data } = features[2]; + + await test.step('step-1: Go to Reading-time with text test page', async () => { + await page.goto(`${baseURL}${features[2].path}${miloLibs}`); + await page.waitForLoadState('domcontentloaded'); + await expect(page).toHaveURL(`${baseURL}${features[2].path}${miloLibs}`); + }); + + await test.step('step-2: Verify Reading-time with text specs', async () => { + await expect(await readingTime.readingTime).toBeVisible(); + await expect(await readingTime.detailM).toContainText(data.detailText); + await expect(await readingTime.headingXL).toContainText(data.h2Text); + await expect(await readingTime.bodyM).toContainText(data.bodyText); + await expect(await readingTime.outlineButtonL).toContainText(data.outlineButtonText); + await expect(await readingTime.blueButtonL).toContainText(data.blueButtonText); + + const actual = parseInt((await readingTime.getReadingText()).split(' ')[0], 10); + const contentText = await readingTime.getContentText(); + const wordCount = contentText.trim().split(/\s+/).length; + const expected = Math.ceil(wordCount / 200); + expect(Math.abs(actual - expected)).toBeLessThanOrEqual(1); + + for (const sectionId of ['s2', 's3', 's4', 's5']) { + const section = readingTime.page.locator(`.section[daa-lh="${sectionId}"]`); + const heading = section.locator('h3.heading-l'); + await expect(heading).toContainText(data.h3Text); + const paragraphs = section.locator('p.body-l'); + await expect(paragraphs.nth(0)).toContainText(data.bodyTextL); + await expect(paragraphs.nth(1)).toContainText(data.bodyTextL); + const listItems = section.locator('ul.body-l > li'); + await expect(listItems).toHaveCount(3); + for (let i = 0; i < 3; i++) { + await expect(listItems.nth(i)).toContainText(data.listItemsText); + } + } + }); + + await test.step('step-3: Verify analytics attributes', async () => { + await expect(readingTime.readingTime).toHaveAttribute('daa-lh', 'b1|reading-time'); + expect(await webUtil.verifyAttributes(readingTime.assetImage, readingTime.attributes['asset.image'].assetImg)).toBeTruthy(); + }); + + await test.step('step-4: Verify the accessibility test on Reading-time with text block', async () => { + await runAccessibilityTest({ page, testScope: readingTime.readingTime, skipA11yTest: true }); + }); + }); +}); diff --git a/test/blocks/graybox/graybox.test.js b/test/blocks/graybox/graybox.test.js index 36d8605413a..563d2b949f9 100644 --- a/test/blocks/graybox/graybox.test.js +++ b/test/blocks/graybox/graybox.test.js @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-globals */ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; import { loadStyle, MILO_EVENTS } from '../../../libs/utils/utils.js'; @@ -58,7 +59,7 @@ describe('Graybox', () => { }); it('Puts an overlay on the entire page', async () => { - const overlayDiv = document.querySelector('body > .gb-page-overlay'); + const overlayDiv = document.querySelector('body > main >.gb-page-overlay'); expect(overlayDiv).to.exist; const beforeStyle = window.getComputedStyle(overlayDiv); expect(beforeStyle.backgroundColor).to.equal('rgba(0, 0, 0, 0.45)'); @@ -247,3 +248,104 @@ describe('getGrayboxEnv', () => { expect(result).to.equal('test'); }); }); + +describe('Graybox advanced behaviors', () => { + let originalInnerWidth; + let originalUrl; + + beforeEach(async () => { + // Save and mock window properties + originalInnerWidth = window.innerWidth; + originalUrl = window.location.href; + // Use a graybox-like path for env (cannot change domain in browser tests) + history.replaceState({}, '', '/page'); + document.body.innerHTML = await readFile({ path: './mocks/graybox.html' }); + window.milo = { deferredPromise: Promise.resolve() }; + await init(document.querySelector('.graybox')); + }); + + afterEach(() => { + window.innerWidth = originalInnerWidth; + history.replaceState({}, '', originalUrl); + }); + + // NOTE: In browser-based test runners, you cannot change the domain/origin. + // All tests must use relative URLs (path/query/hash) only. + + it('rewrites .adobe.com links to graybox domains on load', () => { + const link = document.querySelector('a[href*="adobe.com"]'); + // This will not rewrite to graybox domain in browser tests, but should still be a valid link + expect(link).to.exist; + // We cannot assert the domain, but we can check the href is still present + expect(link.href).to.include('adobe.com'); + }); + + it('rewrites dynamically added .adobe.com links via MutationObserver', (done) => { + const newLink = document.createElement('a'); + newLink.href = 'https://www.adobe.com/somepath'; + newLink.textContent = 'Dynamic Adobe Link'; + const p = document.createElement('p'); + p.appendChild(newLink); + document.body.appendChild(p); + // Wait for MutationObserver to process + setTimeout(() => { + // We cannot assert the domain, but we can check the href is still present + expect(newLink.href).to.include('adobe.com'); + done(); + }, 50); + }); + + it('toggles overlay on/off via window message event', () => { + expect(document.body.classList.contains('gb-overlay-off')).to.be.false; + window.dispatchEvent(new MessageEvent('message', { data: 'gb-overlay-off' })); + expect(document.body.classList.contains('gb-overlay-off')).to.be.true; + window.dispatchEvent(new MessageEvent('message', { data: 'gb-overlay-on' })); + expect(document.body.classList.contains('gb-overlay-off')).to.be.false; + }); + + it('respects graybox-overlay=off URL param and disables overlay on load', async () => { + // Set the URL before loading HTML and calling init + history.replaceState({}, '', '/page?graybox-overlay=off'); + document.body.innerHTML = await readFile({ path: './mocks/graybox.html' }); + window.milo = { deferredPromise: Promise.resolve() }; + await init(document.querySelector('.graybox')); + // Wait for any async DOM updates + await new Promise((resolve) => { setTimeout(resolve, 50); }); + expect(document.body.classList.contains('gb-overlay-off')).to.be.true; + const input = document.getElementById('gb-overlay-toggle'); + expect(input.checked).to.be.false; + }); + + it('disables overlay toggle if no relevant elements exist', async () => { + document.body.innerHTML = await readFile({ path: './mocks/graybox.html' }); + // Remove all relevant classes BEFORE calling init + document.querySelectorAll('.gb-changed, .gb-no-change, .gb-no-click, .gb-page-overlay').forEach((el) => { el.className = ''; }); + window.milo = { deferredPromise: Promise.resolve() }; + await init(document.querySelector('.graybox')); + // Wait for any async DOM updates + await new Promise((resolve) => { setTimeout(resolve, 50); }); + const switchDiv = document.querySelector('.spectrum-Switch'); + expect(switchDiv.classList.contains('gb-toggle-disabled')).to.be.true; + }); + + it('hides device menu on small screens', async () => { + window.innerWidth = 500; + document.body.innerHTML = await readFile({ path: './mocks/graybox.html' }); + window.milo = { deferredPromise: Promise.resolve() }; + await init(document.querySelector('.graybox')); + const menu = document.querySelector('.graybox-menu'); + expect(menu.classList.contains('hide-devices')).to.be.true; + }); + + it('menu-off mode disables border and respects overlay state', async () => { + // Simulate menu-off and overlay-off using relative path + history.replaceState({}, '', '/page?graybox=menu-off&graybox-overlay=off'); + document.body.innerHTML = await readFile({ path: './mocks/graybox.html' }); + window.milo = { deferredPromise: Promise.resolve() }; + await init(document.querySelector('.graybox')); + expect(document.body.classList.contains('gb-no-border')).to.be.true; + expect(document.body.classList.contains('gb-overlay-off')).to.be.true; + // Menu should not be present + expect(document.querySelector('.graybox-container')).to.not.exist; + }); +}); diff --git a/test/blocks/hero-marquee/hero-marquee.test.js b/test/blocks/hero-marquee/hero-marquee.test.js index 063f9b7631a..cc34f622845 100644 --- a/test/blocks/hero-marquee/hero-marquee.test.js +++ b/test/blocks/hero-marquee/hero-marquee.test.js @@ -3,6 +3,7 @@ import { expect } from '@esm-bundle/chai'; import { stub } from 'sinon'; import { waitForElement } from '../../helpers/waitfor.js'; import { setConfig } from '../../../libs/utils/utils.js'; +import { getViewportOrder } from '../../../libs/blocks/hero-marquee/hero-marquee.js'; window.lana = { log: stub() }; @@ -35,4 +36,39 @@ describe('Hero Marquee', () => { const hr = await waitForElement('.has-divider'); expect(hr).to.exist; }); + it('sorts con-block elements based on order class and viewport', async () => { + const orderHero = await readFile({ path: './mocks/order-marquee.html' }); + const orderMarquee = document.querySelector('#order-hero'); + orderMarquee.innerHTML = orderHero; + const orderCopy = orderMarquee.querySelector('.copy'); + const tabletOrder = getViewportOrder('tablet', orderCopy); + const desktopOrder = getViewportOrder('desktop', orderCopy); + + expect(tabletOrder[0].classList.contains('main-copy')).to.be.true; + expect(desktopOrder[0].classList.contains('main-copy')).to.be.true; + + expect(tabletOrder[1].classList.contains('order-0-tablet')).to.be.true; + expect(desktopOrder[1].classList.contains('no-order-element')).to.be.true; + + expect(tabletOrder[2].classList.contains('no-order-element')).to.be.true; + expect(desktopOrder[2].classList.contains('order-0-desktop')).to.be.true; + + expect(tabletOrder[3].classList.contains('order-1-tablet')).to.be.true; + expect(desktopOrder[3].classList.contains('order-1-tablet')).to.be.true; + + expect(tabletOrder[4].classList.contains('order-2-tablet')).to.be.true; + expect(desktopOrder[4].classList.contains('order-1-desktop')).to.be.true; + + expect(tabletOrder[5].classList.contains('order-3-tablet')).to.be.true; + expect(desktopOrder[5].classList.contains('order-2-desktop')).to.be.true; + }); + it('order of con-blocks is the same as mobile if there is no order class', async () => { + const noOrderHero = await readFile({ path: './mocks/no-order-marquee.html' }); + const noOrderMarquee = document.querySelector('#no-order-hero'); + noOrderMarquee.innerHTML = noOrderHero; + const noOrderCopy = noOrderMarquee.querySelector('.copy'); + const mobileOrder = [...noOrderCopy.children]; + const tabletOrder = getViewportOrder('tablet', noOrderCopy); + for (const [index, el] of tabletOrder.entries()) expect(el === mobileOrder[index]).to.be.true; + }); }); diff --git a/test/blocks/hero-marquee/mocks/body.html b/test/blocks/hero-marquee/mocks/body.html index 013471e69b1..971624768c8 100644 --- a/test/blocks/hero-marquee/mocks/body.html +++ b/test/blocks/hero-marquee/mocks/body.html @@ -121,3 +121,6 @@
See plans for students and teachers or small and medium business.
Text with no button class
Annual, paid monthly
Get 20+ Creative Cloud apps, including Photoshop, Acrobat Pro, and more. Pay the first year and after that.
" + "value": "Annual, paid monthly
Get 20+ Creative Cloud apps, including Photoshop, Acrobat Pro, and more. Pay the first year and after that.
" }, "locReady": true, "mnemonicAlt": ["Creative Cloud All Apps"], diff --git a/test/blocks/merch/merch.test.js b/test/blocks/merch/merch.test.js index e80b21ceebf..33edbf87a4f 100644 --- a/test/blocks/merch/merch.test.js +++ b/test/blocks/merch/merch.test.js @@ -29,6 +29,7 @@ import merch, { openModal, PRICE_TEMPLATE_LEGAL, } from '../../../libs/blocks/merch/merch.js'; +import { localizePreviewLinks } from '../../../libs/blocks/merch/autoblock.js'; import { mockFetch, unmockFetch, readMockText } from './mocks/fetch.js'; import { mockIms, unmockIms } from './mocks/ims.js'; @@ -915,4 +916,33 @@ describe('Merch Block', () => { expect(getOptions(a).fragment).to.be.undefined; }); }); + describe('Localize preview links', () => { + it('check if only preview URL is relative', () => { + const div = document.createElement('div'); + + const a1 = document.createElement('a'); + a1.classList.add('link1'); + a1.setAttribute('href', 'https://main--milo--adobecom.aem.page/test/milo/path'); + div.append(a1); + + const a2 = document.createElement('a'); + a2.classList.add('link2'); + a2.setAttribute('href', 'https://main--cc--adobecom.hlx.live/test/cc/path'); + div.append(a2); + + const a3 = document.createElement('a'); + a3.classList.add('link3'); + a3.setAttribute('href', 'https://mas.adobe.com/studio.html#content-type=merch-card-collection&path=acom'); + div.append(a3); + + const aNoHref = document.createElement('a'); + div.append(aNoHref); + + localizePreviewLinks(div); + + expect(div.querySelector('.link1').getAttribute('href')).to.equal('/test/milo/path'); + expect(div.querySelector('.link2').getAttribute('href')).to.equal('/test/cc/path'); + expect(div.querySelector('.link3').getAttribute('href')).to.equal('https://mas.adobe.com/studio.html#content-type=merch-card-collection&path=acom'); + }); + }); });