From 6e4138cac6e129e3aa54c82aa70a93a5d224e3bd Mon Sep 17 00:00:00 2001 From: sharathkannan <138484653+sharath-kannan@users.noreply.github.com> Date: Wed, 4 Dec 2024 15:37:39 +0530 Subject: [PATCH] Feat(MWPW-146367):Added accessibility player controls (NON MPC) (#3053) * updated feature with accessibility code * video accessiblity added for carousels * added opt-out functionality * fixed linting errors * fixed unit test cases * fixed adobe tv issue * hover and focus added * controls positioned for rtl * hide-controls hash params added * how to block controls position bug fix * dark mode|bug fixes * code enhancement * pause-play bug mouse click bug fix * marquee dark mode|positioning fix * code enhancement * handled marquee backward compatiblity * Added placeholder for labels|indexed video aria-labels * aria-label added for hover play videos * async awaited decorateVideo in video.js and other linting errors * video indexes added * random video index and unit test cases updated * daa-ll is synced along with aria-label * code enhancement * nala test fix|code coverege * nala test bug fix * nala test fix * right-left positioning is done for screens > 600px and a img fix * getFedsconfig moved to feds file|url fetched from fedRoot function * linting fix * icons adapted to the figma * carousel and how-to fix with other minor fixes * playpause wrapper adjusted for window * icon offset bug fix * indentation of string literal * figma match * figma focus match --- libs/blocks/adobetv/adobetv.css | 1 + libs/blocks/aside/aside.css | 1 + libs/blocks/aside/aside.js | 4 +- libs/blocks/brick/brick.css | 9 + libs/blocks/carousel/carousel.css | 2 +- libs/blocks/carousel/carousel.js | 8 +- libs/blocks/figure/figure.js | 10 +- libs/blocks/global-footer/global-footer.js | 3 +- .../features/profile/dropdown.js | 3 +- .../features/search/gnav-search.js | 2 +- .../global-navigation/global-navigation.js | 6 +- .../global-navigation/utilities/utilities.js | 20 +-- libs/blocks/hero-marquee/hero-marquee.js | 2 +- libs/blocks/how-to/how-to.js | 2 +- libs/blocks/marquee/marquee.js | 2 +- libs/blocks/video/video.css | 149 +++++++++++++++++ libs/features/webapp-prompt/webapp-prompt.js | 2 +- libs/utils/decorate.js | 158 +++++++++++++++++- libs/utils/federated.js | 18 ++ nala/blocks/marquee/marquee.test.js | 10 +- test/blocks/adobetv/adobetv.test.js | 6 +- test/blocks/figure/figure.test.js | 3 +- .../utilities/utilities.test.js | 2 +- test/blocks/how-to/how-to.test.js | 2 +- test/blocks/how-to/mocks/body.html | 2 +- test/blocks/video/mocks/body.html | 9 +- test/blocks/video/video.test.js | 135 ++++++++++++--- 27 files changed, 493 insertions(+), 78 deletions(-) diff --git a/libs/blocks/adobetv/adobetv.css b/libs/blocks/adobetv/adobetv.css index 7907e053ca..28322d4585 100644 --- a/libs/blocks/adobetv/adobetv.css +++ b/libs/blocks/adobetv/adobetv.css @@ -1,4 +1,5 @@ @import url('../../styles/iframe.css'); +@import url('../video/video.css'); a[href*='.mp4'].hide-video { visibility: hidden !important; diff --git a/libs/blocks/aside/aside.css b/libs/blocks/aside/aside.css index 7feb805ada..54615ee978 100644 --- a/libs/blocks/aside/aside.css +++ b/libs/blocks/aside/aside.css @@ -298,6 +298,7 @@ } .aside.rounded-corners .foreground .image img, +.aside.rounded-corners .foreground .image:not(:has(.video-container)) .pause-play-wrapper, .aside.rounded-corners .foreground .image video { border-radius: 16px; } diff --git a/libs/blocks/aside/aside.js b/libs/blocks/aside/aside.js index 7519967de3..92ea2500d4 100644 --- a/libs/blocks/aside/aside.js +++ b/libs/blocks/aside/aside.js @@ -197,8 +197,8 @@ function decorateLayout(el) { } const foregroundImage = foreground.querySelector(':scope > div:not(.text) img')?.closest('div'); const bgImage = el.querySelector(':scope > div:not(.text):not(.foreground) img')?.closest('div'); - const foregroundMedia = foreground.querySelector(':scope > div:not(.text) video, :scope > div:not(.text) a:is([href*=".mp4"], [href*="tv.adobe.com"]), :scope > div:not(.text) iframe[src*="tv.adobe.com"]')?.closest('div'); - + const foregroundMedia = foreground.querySelector(':scope > div:not(.text) :is(.video-container, video, a[href*=".mp4"], a[href*="tv.adobe.com"]), :scope > div:not(.text) iframe[src*="tv.adobe.com"]') + ?.closest('div:not(.video-container)'); const bgMedia = el.querySelector(':scope > div:not(.text):not(.foreground) video, :scope > div:not(.text):not(.foreground) a:is([href*=".mp4"], [href*="tv.adobe.com"])')?.closest('div'); const image = foregroundImage ?? bgImage; const asideMedia = foregroundMedia ?? bgMedia ?? image; diff --git a/libs/blocks/brick/brick.css b/libs/blocks/brick/brick.css index 43cd31584d..4ed08eb815 100644 --- a/libs/blocks/brick/brick.css +++ b/libs/blocks/brick/brick.css @@ -98,6 +98,10 @@ margin: 0; } +.brick .foreground div > .video-container { + margin: 0; +} + .brick .foreground div > * { margin-top: var(--spacing-xs); } @@ -342,6 +346,11 @@ position: absolute; } + .brick.split.row .foreground .brick-media .video-container img, + .brick.split.row .foreground .brick-media .video-container video { + width: 100%; + } + .brick .foreground .brick-media video, .brick.split.row .foreground .brick-media video { object-fit: fill; diff --git a/libs/blocks/carousel/carousel.css b/libs/blocks/carousel/carousel.css index 41e70a1ef2..08bffdf704 100644 --- a/libs/blocks/carousel/carousel.css +++ b/libs/blocks/carousel/carousel.css @@ -397,7 +397,7 @@ html[dir="rtl"] .carousel-slides .section.carousel-slide { overflow: hidden; } -.carousel .carousel-slide > div p > video { +.carousel .carousel-slide > div p :is(.video-holder, video) { width: 100%; height: auto; } diff --git a/libs/blocks/carousel/carousel.js b/libs/blocks/carousel/carousel.js index 1bd73281f6..4bb57f7180 100644 --- a/libs/blocks/carousel/carousel.js +++ b/libs/blocks/carousel/carousel.js @@ -168,7 +168,7 @@ function moveSlides(event, carouselElements, jumpToIndex) { referenceSlide.classList.remove('reference-slide'); referenceSlide.style.order = null; activeSlide.classList.remove('active'); - activeSlide.querySelectorAll('a').forEach((focusableElement) => { focusableElement.setAttribute('tabindex', -1); }); + activeSlide.querySelectorAll('a, video').forEach((focusableElement) => focusableElement.setAttribute('tabindex', -1)); activeSlideIndicator.classList.remove('active'); activeSlideIndicator.setAttribute('tabindex', -1); @@ -230,10 +230,12 @@ function moveSlides(event, carouselElements, jumpToIndex) { if (index < show) { tabIndex = 0; } - slide.querySelectorAll('a').forEach((focusableElement) => { focusableElement.setAttribute('tabindex', tabIndex); }); + slide.querySelectorAll('a,:not(.video-container, .pause-play-wrapper) > video') + .forEach((focusableElement) => { focusableElement.setAttribute('tabindex', tabIndex); }); }); } else { - activeSlide.querySelectorAll('a').forEach((focusableElement) => { focusableElement.setAttribute('tabindex', 0); }); + activeSlide.querySelectorAll('a,:not(.video-container, .pause-play-wrapper) > video') + .forEach((focusableElement) => { focusableElement.setAttribute('tabindex', 0); }); } activeSlideIndicator.classList.add('active'); activeSlideIndicator.setAttribute('tabindex', 0); diff --git a/libs/blocks/figure/figure.js b/libs/blocks/figure/figure.js index cd0d80cd88..40c9e40c28 100644 --- a/libs/blocks/figure/figure.js +++ b/libs/blocks/figure/figure.js @@ -1,4 +1,4 @@ -import { applyHoverPlay, decorateAnchorVideo } from '../../utils/decorate.js'; +import { applyHoverPlay, decorateAnchorVideo, applyAccessibilityEvents, decoratePausePlayWrapper, isVideoAccessible } from '../../utils/decorate.js'; import { createTag } from '../../utils/utils.js'; function buildCaption(pEl) { @@ -31,7 +31,11 @@ function decorateVideo(clone, figEl) { ); } applyHoverPlay(videoTag); - figEl.prepend(videoTag); + if (!videoTag.controls && isVideoAccessible(anchorTag)) { + applyAccessibilityEvents(videoTag); + decoratePausePlayWrapper(videoTag, 'autoplay'); + } + figEl.prepend(clone.querySelector('.video-container, .pause-play-wrapper, video')); } } @@ -68,7 +72,7 @@ export function buildFigure(blockEl) { const link = clone.querySelector('a'); if (link) { const img = figEl.querySelector('picture') || figEl.querySelector('video'); - if (img) { + if (img && !link.classList.contains('pause-play-wrapper')) { // wrap picture or video in A tag link.textContent = ''; link.append(img); diff --git a/libs/blocks/global-footer/global-footer.js b/libs/blocks/global-footer/global-footer.js index 6c74620291..21e18208cd 100644 --- a/libs/blocks/global-footer/global-footer.js +++ b/libs/blocks/global-footer/global-footer.js @@ -9,7 +9,6 @@ import { } from '../../utils/utils.js'; import { - getFedsPlaceholderConfig, getExperienceName, getAnalyticsValue, loadDecorateMenu, @@ -23,7 +22,7 @@ import { isDarkMode, } from '../global-navigation/utilities/utilities.js'; -import { getFederatedUrl } from '../../utils/federated.js'; +import { getFederatedUrl, getFedsPlaceholderConfig } from '../../utils/federated.js'; import { replaceKey } from '../../features/placeholders.js'; diff --git a/libs/blocks/global-navigation/features/profile/dropdown.js b/libs/blocks/global-navigation/features/profile/dropdown.js index 836f9cd834..d874963e82 100644 --- a/libs/blocks/global-navigation/features/profile/dropdown.js +++ b/libs/blocks/global-navigation/features/profile/dropdown.js @@ -1,6 +1,7 @@ import { getConfig } from '../../../../utils/utils.js'; -import { toFragment, getFedsPlaceholderConfig, trigger, closeAllDropdowns, logErrorFor } from '../../utilities/utilities.js'; +import { toFragment, trigger, closeAllDropdowns, logErrorFor } from '../../utilities/utilities.js'; import { replaceKeyArray } from '../../../../features/placeholders.js'; +import { getFedsPlaceholderConfig } from '../../../../utils/federated.js'; const getLanguage = (ietfLocale) => { if (!ietfLocale.length) return 'en'; diff --git a/libs/blocks/global-navigation/features/search/gnav-search.js b/libs/blocks/global-navigation/features/search/gnav-search.js index ca6df552b8..76532c5d31 100644 --- a/libs/blocks/global-navigation/features/search/gnav-search.js +++ b/libs/blocks/global-navigation/features/search/gnav-search.js @@ -1,6 +1,5 @@ import { toFragment, - getFedsPlaceholderConfig, isDesktop, setCurtainState, trigger, @@ -10,6 +9,7 @@ import { import { replaceKeyArray } from '../../../../features/placeholders.js'; import { getConfig } from '../../../../utils/utils.js'; import { debounce } from '../../../../utils/action.js'; +import { getFedsPlaceholderConfig } from '../../../../utils/federated.js'; const CONFIG = { suggestions: { diff --git a/libs/blocks/global-navigation/global-navigation.js b/libs/blocks/global-navigation/global-navigation.js index 55815c5994..9d13051e86 100644 --- a/libs/blocks/global-navigation/global-navigation.js +++ b/libs/blocks/global-navigation/global-navigation.js @@ -14,7 +14,6 @@ import { getActiveLink, getAnalyticsValue, getExperienceName, - getFedsPlaceholderConfig, hasActiveLink, isActiveLink, icons, @@ -40,6 +39,7 @@ import { setDisableAEDState, getDisableAEDState, } from './utilities/utilities.js'; +import { getFedsPlaceholderConfig } from '../../utils/federated.js'; import { replaceKey, replaceKeyArray } from '../../features/placeholders.js'; @@ -679,7 +679,7 @@ class Gnav { return this.loadDelayed().then(() => { this.blocks.search.instance = new this.Search(this.blocks.search.config); - }).catch(() => {}); + }).catch(() => { }); }; isToggleExpanded = () => this.elements.mobileToggle?.getAttribute('aria-expanded') === 'true'; @@ -773,7 +773,7 @@ class Gnav { if (allSvgImgs.length === 2) return allSvgImgs[1]; const images = blockLinks.filter((blockLink) => imgRegex.test(blockLink.href) - || imgRegex.test(blockLink.textContent)); + || imgRegex.test(blockLink.textContent)); if (images.length === 2) return getBrandImage(images[1], isBrandImage); } const svgImg = rawBlock.querySelector('picture img[src$=".svg"]'); diff --git a/libs/blocks/global-navigation/utilities/utilities.js b/libs/blocks/global-navigation/utilities/utilities.js index 370ae1f2ca..f880d14ae1 100644 --- a/libs/blocks/global-navigation/utilities/utilities.js +++ b/libs/blocks/global-navigation/utilities/utilities.js @@ -2,7 +2,7 @@ import { getConfig, getMetadata, loadStyle, loadLana, decorateLinks, localizeLink, } from '../../../utils/utils.js'; -import { getFederatedContentRoot, getFederatedUrl } from '../../../utils/federated.js'; +import { getFederatedContentRoot, getFederatedUrl, getFedsPlaceholderConfig } from '../../../utils/federated.js'; import { processTrackingLabels } from '../../../martech/attributes.js'; import { replaceText } from '../../../features/placeholders.js'; @@ -108,24 +108,6 @@ export const federatePictureSources = ({ section, forceFederate } = {}) => { }); }; -let fedsPlaceholderConfig; -export const getFedsPlaceholderConfig = ({ useCache = true } = {}) => { - if (useCache && fedsPlaceholderConfig) return fedsPlaceholderConfig; - - const { locale, placeholders } = getConfig(); - const libOrigin = getFederatedContentRoot(); - - fedsPlaceholderConfig = { - locale: { - ...locale, - contentRoot: `${libOrigin}${locale.prefix}/federal/globalnav`, - }, - placeholders, - }; - - return fedsPlaceholderConfig; -}; - export function getAnalyticsValue(str, index) { if (typeof str !== 'string' || !str.length) return str; diff --git a/libs/blocks/hero-marquee/hero-marquee.js b/libs/blocks/hero-marquee/hero-marquee.js index c9f409757d..f747559e29 100644 --- a/libs/blocks/hero-marquee/hero-marquee.js +++ b/libs/blocks/hero-marquee/hero-marquee.js @@ -187,7 +187,7 @@ export default async function init(el) { foreground.classList.add('foreground', `cols-${fRows.length}`); let copy = fRows[0]; const anyTag = foreground.querySelector('p, h1, h2, h3, h4, h5, h6'); - const asset = foreground.querySelector('div > picture, div > video, div > a[href*=".mp4"], div > a.image-link'); + const asset = foreground.querySelector('div > picture, :is(.video-container, .pause-play-wrapper), div > video, div > a[href*=".mp4"], div > a.image-link'); const allRows = foreground.querySelectorAll('div > div'); copy = anyTag.closest('div'); copy.classList.add('copy'); diff --git a/libs/blocks/how-to/how-to.js b/libs/blocks/how-to/how-to.js index ce7cf146df..8080319fd0 100644 --- a/libs/blocks/how-to/how-to.js +++ b/libs/blocks/how-to/how-to.js @@ -47,7 +47,7 @@ const setJsonLd = (heading, description, mainImage, stepsLd) => { }; const getImage = (el) => el.querySelector('picture') || el.querySelector('a[href$=".svg"'); -const getVideo = (el) => el.querySelector('video') || el.querySelector('.milo-video'); +const getVideo = (el) => el.querySelector('.video-container, .pause-play-wrapper, video, .milo-video'); const getHowToInfo = (el) => { const infoDiv = el.querySelector(':scope > div > div'); diff --git a/libs/blocks/marquee/marquee.js b/libs/blocks/marquee/marquee.js index d747586fd3..21c3e8c8a5 100644 --- a/libs/blocks/marquee/marquee.js +++ b/libs/blocks/marquee/marquee.js @@ -88,7 +88,7 @@ function decorateSplit(el, foreground, media) { let mediaCreditInner; const txtContent = media?.lastChild?.textContent?.trim(); - if (txtContent?.match(/^http.*\.mp4/) || media?.lastChild?.tagName === 'VIDEO') return; + if (txtContent?.match(/^http.*\.mp4/) || media?.lastChild?.tagName === 'VIDEO' || media.querySelector('.video-holder video')) return; if (txtContent) { mediaCreditInner = createTag('p', { class: 'body-s' }, txtContent); } else if (media.lastElementChild?.tagName !== 'PICTURE') { diff --git a/libs/blocks/video/video.css b/libs/blocks/video/video.css index f6ec58c065..6538e0596a 100644 --- a/libs/blocks/video/video.css +++ b/libs/blocks/video/video.css @@ -5,4 +5,153 @@ a[href*='.mp4'].hide-video { video { max-width: 100%; height: auto; + object-fit: cover; +} + +:is(.marquee, .aside.split) .pause-play-wrapper img.accessibility-control { + min-height: 40px; +} + +.video-container { + display: flex; + position: relative; + height: 100%; + width: fit-content; + margin: auto; +} + +:is(.aside, .marquee, .quiz-marquee) .video-container { + width: auto; +} + +.brick-media .video-container { + width: 100%; + height: 100%; + border-radius: inherit; +} + +.pause-play-wrapper { + display: flex; + width: fit-content; + margin: auto; +} + +.video-container .pause-play-wrapper { + position: absolute; + bottom: 2%; + right: 2%; + margin: 0; + justify-content: center; + align-items: center; + border-radius: 50%; + z-index: 2; + padding: 3px; + cursor: pointer; +} + +.video-container .pause-play-wrapper .offset-filler { + display: inherit; + justify-content: center; + align-items: center; + width: 40px; + height: 40px; + border-radius: inherit; + background: var(--color-gray-800); +} + +:is(.marquee:not(.light), .dark) .video-container .pause-play-wrapper { + padding: 1px; +} + +:is(.marquee:not(.light), .dark) .video-container .pause-play-wrapper:focus-visible { + background: #000; +} + +:is(.marquee:not(.light), .dark) .video-container .pause-play-wrapper .offset-filler { + border: 2px solid #fff; +} + +.video-container .pause-play-wrapper:focus-visible { + background: #fff; +} + +.video-container .pause-play-wrapper .offset-filler:hover { + background: #000; +} + +.video-container .pause-play-wrapper:focus-visible { + outline: var(--color-accent-focus-ring) solid 2px; +} + +.video-container .pause-play-wrapper .offset-filler.is-playing .play-icon, +.video-container .pause-play-wrapper .offset-filler:not(.is-playing) .pause-icon { + display: none; +} + +:is(.editorial-card, .hero-marquee, .marquee):not(:has(.video-container)) .pause-play-wrapper, +.editorial-card .video-container { + width: auto; + height: 100%; +} + +.brick .brick-media:not(:has(.video-container)) .pause-play-wrapper { + border-radius: 0; + border-top-right-radius: inherit; + border-bottom-right-radius: inherit; + width: auto; + height: 100%; + margin: 0; +} + +[dir="rtl"] .brick .brick-media:not(:has(.video-container)) .pause-play-wrapper { + border-top-left-radius: inherit; + border-bottom-left-radius: inherit; +} + +.hero-marquee .background .video-container { + position: inherit; +} + +:is(.video-container .pause-play-wrapper, .aside.split.split-left .split-image) img.accessibility-control { + width: auto; +} + +.video-container .pause-play-wrapper img.hidden { + display: none; +} + +.marquee .background .video-container { + display: contents; +} + +.how-to .how-to-media .video-container { + height: fit-content; +} + +@media (min-width: 600px) { + .media:not(.media-reverse-mobile, .media-reversed) .video-container .pause-play-wrapper, + :is(.marquee.row-reversed .asset, .marquee-anchors, .hero-marquee.asset-left) .video-container .pause-play-wrapper, + :is(.aside:not(.split), .aside.split.split-right) .video-container .pause-play-wrapper { + left: 2%; + } + + :is(.section[class*="-up"] .media .foreground .image:first-child, .aside .foreground .image:nth-last-child(1)) + .video-container .pause-play-wrapper { + left: auto; + right: 2%; + } + + [dir="rtl"] :is(.marquee.split:not(.row-reversed), .media:is(.media-reverse-mobile, .media-reversed), + .hero-marquee:not(.asset-left) :is([class*="foreground"]), .aside.split:not(.split-right), + .aside .foreground.container .image:nth-last-child(1), .brick.media-right, .how-to) .video-container .pause-play-wrapper { + left: 2%; + right: auto; + } +} + +@media (min-width: 600px) and (max-width: 1199px) { + .hero-marquee.asset-left .video-container.video-holder .pause-play-wrapper { + left: auto; + right: 2%; + } } diff --git a/libs/features/webapp-prompt/webapp-prompt.js b/libs/features/webapp-prompt/webapp-prompt.js index ea9bc072d0..894d010d0b 100644 --- a/libs/features/webapp-prompt/webapp-prompt.js +++ b/libs/features/webapp-prompt/webapp-prompt.js @@ -1,5 +1,4 @@ import { - getFedsPlaceholderConfig, getUserProfile, icons, lanaLog, @@ -7,6 +6,7 @@ import { } from '../../blocks/global-navigation/utilities/utilities.js'; import { getConfig, decorateSVG } from '../../utils/utils.js'; import { replaceKey, replaceText } from '../placeholders.js'; +import { getFedsPlaceholderConfig } from '../../utils/federated.js'; export const DISMISSAL_CONFIG = { animationCount: 2, diff --git a/libs/utils/decorate.js b/libs/utils/decorate.js index 36e23dd055..580d2fcc1f 100644 --- a/libs/utils/decorate.js +++ b/libs/utils/decorate.js @@ -1,6 +1,17 @@ import { createTag, loadStyle, getConfig, createIntersectionObserver } from './utils.js'; +import { getFederatedContentRoot, getFedsPlaceholderConfig } from './federated.js'; const { miloLibs, codeRoot } = getConfig(); +const HIDE_CONTROLS = '_hide-controls'; +let firstVideo = null; +let videoLabels = { + playMotion: 'Play', + pauseMotion: 'Pause', + pauseIcon: 'Pause icon', + playIcon: 'Play icon', + hasFetched: false, +}; +let videoCounter = 0; export function decorateButtons(el, size) { const buttons = el.querySelectorAll('em a, strong a, p > a strong'); @@ -209,7 +220,7 @@ export function getImgSrc(pic) { return source?.srcset ? `poster='${source.srcset}'` : ''; } -function getVideoAttrs(hash, dataset) { +export function getVideoAttrs(hash, dataset) { const isAutoplay = hash?.includes('autoplay'); const isAutoplayOnce = hash?.includes('autoplay1'); const playOnHover = hash?.includes('hoverplay'); @@ -234,12 +245,80 @@ function getVideoAttrs(hash, dataset) { return `${globalAttrs} controls`; } +export function syncPausePlayIcon(video) { + if (!video.getAttributeNames().includes('data-hoverplay')) { + const offsetFiller = video.closest('.video-holder').querySelector('.offset-filler'); + const anchorTag = video.closest('.video-holder').querySelector('a'); + offsetFiller?.classList.toggle('is-playing'); + const isPlaying = offsetFiller?.classList.contains('is-playing'); + const indexOfVideo = (anchorTag.getAttribute('video-index') === '1' && videoCounter === 1) ? '' : anchorTag.getAttribute('video-index'); + const changedLabel = `${isPlaying ? videoLabels?.pauseMotion : videoLabels?.playMotion}`; + const oldLabel = `${!isPlaying ? videoLabels?.pauseMotion : videoLabels?.playMotion}`; + const ariaLabel = `${changedLabel} ${indexOfVideo}`.trim(); + anchorTag?.setAttribute('aria-label', `${ariaLabel} `); + anchorTag?.setAttribute('aria-pressed', isPlaying ? 'true' : 'false'); + const daaLL = anchorTag.getAttribute('daa-ll'); + if (daaLL) anchorTag.setAttribute('daa-ll', daaLL.replace(oldLabel, changedLabel)); + } +} + +export function addAccessibilityControl(videoString, videoAttrs, indexOfVideo, tabIndex = 0) { + if (videoAttrs.includes('controls')) return videoString; + const fedRoot = getFederatedContentRoot(); + if (videoAttrs.includes('hoverplay')) { + return `${videoString}`; + } + return ` +
${videoString} + +
+ + +
+
+
+ `; +} + +export function handlePause(event) { + event.stopPropagation(); + if (event.code !== 'Enter' && event.code !== 'Space' && !['focus', 'click', 'blur'].includes(event.type)) { + return; + } + event.preventDefault(); + const video = event.target.closest('.video-holder').parentElement.querySelector('video'); + if (event.type === 'blur') { + video.pause(); + } else if (video.paused || video.ended || event.type === 'focus') { + video.play(); + } else { + video.pause(); + } + syncPausePlayIcon(video); +} + export function applyHoverPlay(video) { if (!video) return; - if (video.hasAttribute('data-hoverplay') && !video.hasAttribute('data-mouseevent')) { - video.addEventListener('mouseenter', () => { video.play(); }); - video.addEventListener('mouseleave', () => { video.pause(); }); - video.setAttribute('data-mouseevent', true); + if (video.hasAttribute('data-hoverplay')) { + video.parentElement.addEventListener('focus', handlePause); + video.parentElement.addEventListener('blur', handlePause); + if (!video.hasAttribute('data-mouseevent')) { + video.addEventListener('mouseenter', () => { video.play(); }); + video.addEventListener('mouseleave', () => { video.pause(); }); + video.addEventListener('ended', () => { syncPausePlayIcon(video); }); + video.setAttribute('data-mouseevent', true); + } + } +} + +export function applyAccessibilityEvents(videoEl) { + const pausePlayWrapper = videoEl.parentElement.querySelector('.pause-play-wrapper') || videoEl.closest('.pause-play-wrapper'); + if (pausePlayWrapper?.querySelector('.accessibility-control')) { + pausePlayWrapper.addEventListener('click', handlePause); + pausePlayWrapper.addEventListener('keydown', handlePause); + } + if (videoEl.hasAttribute('autoplay')) { + videoEl.addEventListener('ended', () => { syncPausePlayIcon(videoEl); }); } } @@ -275,7 +354,7 @@ function getVideoIntersectionObserver() { const isHaveLoopAttr = video.getAttributeNames().includes('loop'); const { playedOnce = false } = video.dataset; const isPlaying = video.currentTime > 0 && !video.paused && !video.ended - && video.readyState > video.HAVE_CURRENT_DATA; + && video.readyState > video.HAVE_CURRENT_DATA; if (intersectionRatio <= 0.8) { video.pause(); @@ -331,13 +410,72 @@ export async function loadCDT(el, classList) { } } +export function isVideoAccessible(anchorTag) { + return !anchorTag?.hash.includes(HIDE_CONTROLS); +} + +function updateFirstVideo() { + if (firstVideo != null && firstVideo?.controls === false && videoCounter > 1) { + let videoHolder = document.querySelector('[video-index="1"]') || firstVideo.closest('.video-holder'); + if (videoHolder.nodeName !== 'A') videoHolder = videoHolder.querySelector('a.pause-play-wrapper'); + const firstVideoLabel = videoHolder.getAttribute('aria-label'); + videoHolder.setAttribute('aria-label', `${firstVideoLabel} 1`); + firstVideo = null; + } +} + +function updateAriaLabel(videoEl, videoAttrs) { + if (!videoEl.getAttributeNames().includes('data-hoverplay')) { + const pausePlayWrapper = videoEl.parentElement.querySelector('.pause-play-wrapper') || videoEl.closest('.pause-play-wrapper'); + const pauseIcon = pausePlayWrapper.querySelector('.pause-icon'); + const playIcon = pausePlayWrapper.querySelector('.play-icon'); + const indexOfVideo = pausePlayWrapper.getAttribute('video-index'); + let ariaLabel = `${videoAttrs.includes('autoplay') ? videoLabels.pauseMotion : videoLabels.playMotion}`; + ariaLabel = ariaLabel.concat(` ${indexOfVideo === '1' && videoCounter === 1 ? '' : indexOfVideo}`); + pausePlayWrapper.setAttribute('aria-label', ariaLabel); + pauseIcon.setAttribute('alt', videoLabels.pauseMotion); + playIcon.setAttribute('alt', videoLabels.playMotion); + updateFirstVideo(); + } +} + +export function decoratePausePlayWrapper(videoEl, videoAttrs) { + if (!videoLabels.hasFetched) { + import('../features/placeholders.js').then(({ replaceKeyArray }) => { + replaceKeyArray(['pause-motion', 'play-motion', 'pause-icon', 'play-icon'], getFedsPlaceholderConfig()) + .then(([pauseMotion, playMotion, pauseIcon, playIcon]) => { + videoLabels = { playMotion, pauseMotion, pauseIcon, playIcon }; + videoLabels.hasFetched = true; + updateAriaLabel(videoEl, videoAttrs); + }); + }); + } else { + updateAriaLabel(videoEl, videoAttrs); + } +} + export function decorateAnchorVideo({ src = '', anchorTag }) { if (!src.length || !(anchorTag instanceof HTMLElement)) return; + const accessibilityEnabled = isVideoAccessible(anchorTag); + anchorTag.hash = anchorTag.hash.replace(`#${HIDE_CONTROLS}`, ''); if (anchorTag.closest('.marquee, .aside, .hero-marquee, .quiz-marquee') && !anchorTag.hash) anchorTag.hash = '#autoplay'; const { dataset, parentElement } = anchorTag; - const video = ``; + const attrs = getVideoAttrs(anchorTag.hash, dataset); + const tabIndex = anchorTag.tabIndex || 0; + const videoIndex = (tabIndex === -1) ? 'tabindex=-1' : ''; + let video = ``; + if (!attrs.includes('controls') && !attrs.includes('hoverplay') && accessibilityEnabled) { + videoCounter += 1; + } + const indexOfVideo = videoCounter; + if (accessibilityEnabled) { + video = addAccessibilityControl(video, attrs, indexOfVideo, tabIndex); + } anchorTag.insertAdjacentHTML('afterend', video); const videoEl = parentElement.querySelector('video'); + if (indexOfVideo === 1) { + firstVideo = videoEl; + } createIntersectionObserver({ el: videoEl, options: { rootMargin: '1000px' }, @@ -345,6 +483,12 @@ export function decorateAnchorVideo({ src = '', anchorTag }) { videoEl?.appendChild(createTag('source', { src, type: 'video/mp4' })); }, }); + if (accessibilityEnabled) { + applyAccessibilityEvents(videoEl); + if (!videoEl.controls) { + decoratePausePlayWrapper(videoEl, attrs); + } + } applyHoverPlay(videoEl); applyInViewPortPlay(videoEl); anchorTag.remove(); diff --git a/libs/utils/federated.js b/libs/utils/federated.js index 759f518eb0..c3e3d3d24e 100644 --- a/libs/utils/federated.js +++ b/libs/utils/federated.js @@ -39,3 +39,21 @@ export const getFederatedUrl = (url = '') => { } return url; }; + +let fedsPlaceholderConfig; +export const getFedsPlaceholderConfig = ({ useCache = true } = {}) => { + if (useCache && fedsPlaceholderConfig) return fedsPlaceholderConfig; + + const { locale, placeholders } = getConfig(); + const libOrigin = getFederatedContentRoot(); + + fedsPlaceholderConfig = { + locale: { + ...locale, + contentRoot: `${libOrigin}${locale.prefix}/federal/globalnav`, + }, + placeholders, + }; + + return fedsPlaceholderConfig; +}; diff --git a/nala/blocks/marquee/marquee.test.js b/nala/blocks/marquee/marquee.test.js index 246503a78b..9f9905e41d 100644 --- a/nala/blocks/marquee/marquee.test.js +++ b/nala/blocks/marquee/marquee.test.js @@ -543,7 +543,7 @@ test.describe('Milo Marquee Block test suite', () => { await test.step('step-3: Verify analytic attributes', async () => { await expect(await marquee.marqueeSmallDark).toHaveAttribute('daa-lh', await webUtil.getBlockDaalh('marquee', 1)); - await expect(await marquee.blueButtonL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 1, data.h2Text)); + await expect(await marquee.blueButtonL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 2, data.h2Text)); }); await test.step('step-4: Verify the accessibility test on the Marquee (small) background video playsinline block', async () => { @@ -570,7 +570,7 @@ test.describe('Milo Marquee Block test suite', () => { await expect(await marquee.headingXXL).toContainText(data.h2Text); await expect(await marquee.bodyXL).toContainText(data.bodyText); await expect(await marquee.blueButtonXL).toContainText(data.blueButtonText); - await expect(await marquee.actionLink2).toContainText(data.linkText); + await expect(await marquee.actionLink3).toContainText(data.linkText); await expect(await marquee.backgroundVideoDesktop).toBeVisible(); expect(await webUtil.verifyAttributes(marquee.backgroundVideoDesktop, marquee.attributes['backgroundVideo.inline'])).toBeTruthy(); @@ -581,8 +581,8 @@ test.describe('Milo Marquee Block test suite', () => { await test.step('step-3: Verify analytic attributes', async () => { await expect(await marquee.marqueeLargeLight).toHaveAttribute('daa-lh', await webUtil.getBlockDaalh('marquee', 1)); - await expect(await marquee.blueButtonXL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 1, data.h2Text)); - await expect(await marquee.actionLink2).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.linkText, 2, data.h2Text)); + await expect(await marquee.blueButtonXL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 2, data.h2Text)); + await expect(await marquee.actionLink3).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.linkText, 3, data.h2Text)); }); }); @@ -615,7 +615,7 @@ test.describe('Milo Marquee Block test suite', () => { await test.step('step-3: Verify analytic attributes', async () => { await expect(await marquee.marqueeLargeDark).toHaveAttribute('daa-lh', await webUtil.getBlockDaalh('marquee', 2)); - await expect(await marquee.blueButtonXL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 1, data.h2Text)); + await expect(await marquee.blueButtonXL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 2, data.h2Text)); }); }); diff --git a/test/blocks/adobetv/adobetv.test.js b/test/blocks/adobetv/adobetv.test.js index a54528e496..223622c7bd 100644 --- a/test/blocks/adobetv/adobetv.test.js +++ b/test/blocks/adobetv/adobetv.test.js @@ -1,8 +1,10 @@ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; import { waitForElement } from '../../helpers/waitfor.js'; +import { setConfig } from '../../../libs/utils/utils.js'; document.body.innerHTML = await readFile({ path: './mocks/body.html' }); +setConfig({}); const { default: init } = await import('../../../libs/blocks/adobetv/adobetv.js'); describe('adobetv autoblock', () => { @@ -10,7 +12,7 @@ describe('adobetv autoblock', () => { const wrapper = document.body.querySelector('.adobe-tv'); const a = wrapper.querySelector(':scope > a'); - init(a); + await init(a); const iframe = await waitForElement('.adobe-tv iframe'); expect(wrapper.querySelector(':scope > a')).to.be.null; expect(iframe).to.be.exist; @@ -20,7 +22,7 @@ describe('adobetv autoblock', () => { const wrapper = document.body.querySelector('#adobetvAsBg'); const a = wrapper.querySelector(':scope a[href*=".mp4"]'); - init(a); + await init(a); const video = await waitForElement('#adobetvAsBg video'); expect(wrapper.querySelector(':scope a[href*=".mp4"]')).to.be.null; expect(video).to.be.exist; diff --git a/test/blocks/figure/figure.test.js b/test/blocks/figure/figure.test.js index 61ee37b909..6f9d7ab0bf 100644 --- a/test/blocks/figure/figure.test.js +++ b/test/blocks/figure/figure.test.js @@ -1,10 +1,11 @@ import { expect } from '@esm-bundle/chai'; import { readFile } from '@web/test-runner-commands'; +import { setConfig } from '../../../libs/utils/utils.js'; document.body.innerHTML = await readFile({ path: './mocks/body.html' }); const ogDocument = document.body.innerHTML; - const { default: init } = await import('../../../libs/blocks/figure/figure.js'); +setConfig({}); describe('init', () => { afterEach(() => { diff --git a/test/blocks/global-navigation/utilities/utilities.test.js b/test/blocks/global-navigation/utilities/utilities.test.js index c50aec5b2b..895a2ea6e2 100644 --- a/test/blocks/global-navigation/utilities/utilities.test.js +++ b/test/blocks/global-navigation/utilities/utilities.test.js @@ -3,7 +3,6 @@ import sinon from 'sinon'; import { fetchAndProcessPlainHtml, toFragment, - getFedsPlaceholderConfig, federatePictureSources, getAnalyticsValue, decorateCta, @@ -18,6 +17,7 @@ import { import { setConfig, getConfig } from '../../../../libs/utils/utils.js'; import { createFullGlobalNavigation, config } from '../test-utilities.js'; import mepInBlock from '../mocks/mep-config.js'; +import { getFedsPlaceholderConfig } from '../../../../libs/utils/federated.js'; const baseHost = 'https://main--federal--adobecom.aem.page'; describe('global navigation utilities', () => { diff --git a/test/blocks/how-to/how-to.test.js b/test/blocks/how-to/how-to.test.js index fb20b1f962..1e0207d3cf 100644 --- a/test/blocks/how-to/how-to.test.js +++ b/test/blocks/how-to/how-to.test.js @@ -105,7 +105,7 @@ describe('How To', () => { it('Mp4 Link video', async () => { const howTo = document.getElementById('test6'); - videoinit(howTo.querySelector('a')); + await videoinit(howTo.querySelector('a')); init(howTo); const video = howTo.querySelector('video'); expect(video).to.exist; diff --git a/test/blocks/how-to/mocks/body.html b/test/blocks/how-to/mocks/body.html index ff4fe17059..1c2525625c 100644 --- a/test/blocks/how-to/mocks/body.html +++ b/test/blocks/how-to/mocks/body.html @@ -117,7 +117,7 @@

How to compress a PDF online (

How to compress a PDF online (with schema)

Follow these easy steps to compress a large PDF file online:

-

https://main--milo--adobecom.hlx.page/drafts/gunn/media_16965312b3a8d7a1d48a1d510584dc5e8a0f1e085.mp4

+

https://main--milo--adobecom.hlx.page/drafts/gunn/media_16965312b3a8d7a1d48a1d510584dc5e8a0f1e085.mp4

diff --git a/test/blocks/video/mocks/body.html b/test/blocks/video/mocks/body.html index b3f1963c85..1c59b91e82 100644 --- a/test/blocks/video/mocks/body.html +++ b/test/blocks/video/mocks/body.html @@ -16,7 +16,7 @@
-
+ @@ -86,4 +86,9 @@ https://main--milo--adobecom.hlx.page/media_1e798d01c6ddc7e7eadc8f134d69e4f8d7193fdbb6.mp4#autoplay1#_hoverplay#viewportplay
+
+ + https://main--blog--adobecom.hlx.page/media_17927691d22fe4e1bd058e94762a224fdc57ebb7b.mp4#autoplay + +
diff --git a/test/blocks/video/video.test.js b/test/blocks/video/video.test.js index e55c4e2915..c4e167cb20 100644 --- a/test/blocks/video/video.test.js +++ b/test/blocks/video/video.test.js @@ -4,20 +4,44 @@ import sinon from 'sinon'; import { waitFor, waitForElement } from '../../helpers/waitfor.js'; import { setConfig, createTag } from '../../../libs/utils/utils.js'; -import { decorateAnchorVideo } from '../../../libs/utils/decorate.js'; +import { decorateAnchorVideo, handlePause, applyHoverPlay, decoratePausePlayWrapper } from '../../../libs/utils/decorate.js'; setConfig({}); const { default: init } = await import('../../../libs/blocks/video/video.js'); describe('video uploaded using franklin bot', () => { + let clock; + const callback = sinon.spy(); beforeEach(async () => { + clock = sinon.useFakeTimers({ + toFake: ['setTimeout'], + shouldAdvanceTime: true, + }); document.body.innerHTML = await readFile({ path: './mocks/body.html' }); }); afterEach(() => { + clock.restore(); document.body.innerHTML = ''; }); + it('aria-label should not have index when page has only one video', async () => { + const block = document.querySelector('.video.autoplay.single'); + const block2 = document.querySelector('.video.autoplay.second'); + const a = block.querySelector('a'); + const a2 = block2.querySelector('a'); + init(a); + setTimeout(callback, 600); + await clock.runAllAsync(); + const pausePlayWrapper = block.querySelector('.pause-play-wrapper'); + pausePlayWrapper.removeAttribute('video-index'); + init(a2); + setTimeout(callback, 500); + await clock.runAllAsync(); + const videoIndex = pausePlayWrapper.getAttribute('video-index'); + expect(videoIndex).to.be.null; + }); + it('removes the element, if it does not have a parent node', (done) => { const anchor = createTag('a'); anchor.remove = () => done(); @@ -114,6 +138,85 @@ describe('video uploaded using franklin bot', () => { expect(video.hasAttribute('data-play-viewport')).to.be.true; }); + it('accessibility controls should pause autoplay videos', async () => { + const block = document.querySelector('.video.autoplay.viewportplay'); + const fetchStub = sinon.stub(window, 'fetch'); + fetchStub.resolves({ + total: 19, + offset: 0, + limit: 19, + data: [ + { + key: 'play-motion', + value: 'Play', + }, + { + key: 'pause-motion', + value: 'Pause', + }, + { + key: 'play-icon', + value: 'play icon', + }, + { + key: 'pause-icon', + value: 'pause icon', + }, + ], + ':type': 'sheet', + }); + + const a = block.querySelector('a'); + init(a); + const video = block.querySelector('video'); + decoratePausePlayWrapper(video, ''); + const pausePlayWrapper = block.querySelector('.pause-play-wrapper'); + pausePlayWrapper.click(); + setTimeout(callback, 500); + await clock.runAllAsync(); + expect(pausePlayWrapper.ariaPressed).to.eql('false'); + }); + + it('accessibility controls should play autoplay videos after pausing', async () => { + const block = document.querySelector('.video.autoplay.viewportplay'); + const a = block.querySelector('a'); + init(a); + const pausePlayWrapper = block.querySelector('.pause-play-wrapper'); + pausePlayWrapper.click(); + pausePlayWrapper.setAttribute('daa-ll', 'pause-motion'); + setTimeout(callback, 500); + await clock.runAllAsync(); + pausePlayWrapper.click(); + expect(pausePlayWrapper.querySelector('.is-playing')).to.exist; + }); + + it('handlePause should return undefined if called with unknown event', async () => { + const event = {}; + event.stopPropagation = sinon.stub(); + const x = handlePause(event); + expect(x).to.be.undefined; + }); + + it('video should be paused on focus out or blur', async () => { + const block = document.querySelector('.video.autoplay1.hoverplay.no-viewportplay'); + const a = block.querySelector('a'); + init(a); + setTimeout(callback, 0); + await clock.runAllAsync(); + const pausePlayWrapper = block.querySelector('.pause-play-wrapper'); + const video = block.querySelector('video'); + pausePlayWrapper.focus(); + setTimeout(callback, 0); + await clock.runAllAsync(); + pausePlayWrapper.blur(); + expect(video.paused).to.be.true; + }); + + it('should return undefined if video is not present', async () => { + const returnValue = applyHoverPlay(); + expect(returnValue).to.be.undefined; + }); + it('play video when element reached 80% viewport', async () => { const block = document.querySelector('.video.autoplay.viewportplay.scrolled-80'); const a = block.querySelector('a'); @@ -130,9 +233,8 @@ describe('video uploaded using franklin bot', () => { await waitFor(intersectionObserverAddsSource); video.scrollIntoView(); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 100); + await clock.runAllAsync(); assert.isTrue(playSpy.calledOnce); // push the video out of the viewport @@ -141,9 +243,8 @@ describe('video uploaded using franklin bot', () => { video.parentNode.insertBefore(div, video); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 100); + await clock.runAllAsync(); assert.isTrue(pauseSpy.calledOnce); expect(video.hasAttribute('data-play-viewport')).to.be.true; }); @@ -167,9 +268,8 @@ describe('video uploaded using franklin bot', () => { video.addEventListener('ended', endedSpy); video.scrollIntoView(); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 100); + await clock.runAllAsync(); assert.isTrue(playSpy.calledOnce); // push the video out of the viewport @@ -178,21 +278,18 @@ describe('video uploaded using franklin bot', () => { video.parentNode.insertBefore(div, video); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 200); + await clock.runAllAsync(); assert.isTrue(pauseSpy.calledOnce); video.dispatchEvent(new Event('ended')); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 100); + await clock.runAllAsync(); video.scrollIntoView(); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 100); + await clock.runAllAsync(); expect(playSpy.callCount).to.equal(1); expect(video.hasAttribute('data-play-viewport')).to.be.true;