diff --git a/blocks/columns/columns.css b/blocks/columns/columns.css index a0aaaaf..a19a6d2 100644 --- a/blocks/columns/columns.css +++ b/blocks/columns/columns.css @@ -68,30 +68,8 @@ aspect-ratio: 16 / 9; } -.columns .media-wrapper .video-embed iframe { - height: 100%; - width: 100%; - border: none; -} - .columns .media-wrapper .video-embed .placeholder { - position: absolute; - inset: 0; z-index: 1; - margin: 0; - cursor: pointer; -} - -.columns .media-wrapper .video-embed .placeholder .icon { - position: absolute; - top: 50%; - left: 50%; - height: 80px; - width: 80px; - color: var(--color-white); - filter: drop-shadow(0 1px 5px rgb(0 0 0 / 60%)); - transform: translate(-50%, -50%); - pointer-events: none; } @media (width >= 600px) { diff --git a/blocks/columns/columns.js b/blocks/columns/columns.js index 75aba3a..107fe4d 100644 --- a/blocks/columns/columns.js +++ b/blocks/columns/columns.js @@ -1,6 +1,5 @@ import { createOptimizedPicture } from '../../scripts/aem.js'; -import { isYouTubeHref } from '../../scripts/scripts.js'; -import { createYouTubeEmbed, createPlaceholder } from '../video/video.js'; +import { transformVideoLinks } from '../../scripts/scripts.js'; /** * Returns the shared column count if every row has the same number of columns. @@ -49,68 +48,6 @@ function detectAction(block) { }); } -/** - * Returns true if el is a paragraph containing a single YouTube link. - * @param {Element} el - The element to test - * @returns {boolean} - */ -function isVideoLink(el) { - if (el.tagName !== 'P' || el.children.length !== 1) return false; - const a = el.firstElementChild; - return a.tagName === 'A' && isYouTubeHref(a.href); -} - -/** - * Transforms a media column with a YouTube link into a video embed. - * @param {Element} col - The column element to transform - * @param {string} href - The YouTube URL to embed - */ -function decorateVideo(col, href) { - const embed = createYouTubeEmbed(href); - if (!embed) return; - - const container = document.createElement('div'); - container.className = 'video-embed'; - - const picture = col.querySelector('picture'); - const placeholder = createPlaceholder(picture, () => { - const src = new URL(embed.src); - src.searchParams.set('autoplay', 1); - embed.src = src.href; - if (!embed.isConnected) container.append(embed); - }); - - if (placeholder) container.append(placeholder); - col.replaceChildren(container); - col.dataset.media = 'video'; - - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - container.append(embed); - observer.disconnect(); - }); - }, { rootMargin: '0px' }); - observer.observe(container); -} - -/** - * Transforms a video link in a media-only column into a video embed. - * @param {Element} col - Column element to inspect and potentially transform - */ -function transformVideoLinks(col) { - const videoLinkEl = [...col.children].find(isVideoLink); - if (videoLinkEl) { - const nonVideo = [...col.children].filter((el) => el !== videoLinkEl); - const isPictureParagraph = (el) => el.tagName === 'P' - && el.children.length === 1 - && el.firstElementChild.tagName === 'PICTURE'; - if (nonVideo.every((el) => el.tagName === 'PICTURE' || el.tagName === 'VIDEO' || isPictureParagraph(el))) { - decorateVideo(col, videoLinkEl.firstElementChild.href); - } - } -} - /** @param {Element} block */ export default function decorate(block) { [...block.children].forEach((row) => { @@ -124,6 +61,7 @@ export default function decorate(block) { || el.classList.contains('video-embed'), ); col.classList.add(isMedia ? 'media-wrapper' : 'body-wrapper'); + if (els.length === 1 && els[0].classList.contains('video-embed')) col.dataset.media = 'video'; }); if (row.querySelector('.media-wrapper')) row.dataset.row = 'media'; }); diff --git a/blocks/gallery/gallery.css b/blocks/gallery/gallery.css index 5d67d19..cd8a35d 100644 --- a/blocks/gallery/gallery.css +++ b/blocks/gallery/gallery.css @@ -67,37 +67,6 @@ width: 100%; } -.gallery .media-wrapper .video-embed iframe { - height: 100%; - width: 100%; - border: none; -} - -.gallery .media-wrapper .video-embed .placeholder { - position: absolute; - inset: 0; - margin: 0; - cursor: pointer; -} - -.gallery .media-wrapper .video-embed .placeholder img { - height: 100%; - width: 100%; - object-fit: cover; -} - -.gallery .media-wrapper .video-embed .placeholder .icon { - position: absolute; - top: 50%; - left: 50%; - height: 80px; - width: 80px; - color: var(--color-white); - filter: drop-shadow(0 1px 5px rgb(0 0 0 / 60%)); - transform: translate(-50%, -50%); - pointer-events: none; -} - .gallery .body-wrapper { padding: var(--space-s) 0; } diff --git a/blocks/gallery/gallery.js b/blocks/gallery/gallery.js index 5f2d3fb..064d14b 100644 --- a/blocks/gallery/gallery.js +++ b/blocks/gallery/gallery.js @@ -1,59 +1,5 @@ -import { isYouTubeHref, loadCopy } from '../../scripts/scripts.js'; +import { loadCopy, transformVideoLinks } from '../../scripts/scripts.js'; import { createOptimizedPicture, decorateIcons } from '../../scripts/aem.js'; -import { createYouTubeEmbed, createPlaceholder } from '../video/video.js'; - -/** - * Returns `true` if a column child is a paragraph containing a single YouTube link. - * @param {HTMLElement} el - Column child element to test - * @returns {boolean} - */ -function isVideoLink(el) { - if (el.tagName !== 'P' || el.children.length !== 1) return false; - const a = el.firstElementChild; - return a.tagName === 'A' && isYouTubeHref(a.href); -} - -/** - * Replaces a media column's YouTube link with a lazy-loaded embed behind a poster placeholder. - * @param {HTMLElement} col - Media column containing the link - * @param {string} href - YouTube URL to embed - */ -function decorateVideo(col, href) { - const embed = createYouTubeEmbed(href); - if (!embed) return; - - const container = document.createElement('div'); - container.className = 'video-embed'; - - const picture = col.querySelector('picture'); - const placeholder = createPlaceholder(picture, () => { - const src = new URL(embed.src); - src.searchParams.set('autoplay', 1); - embed.src = src.href; - if (!embed.isConnected) container.append(embed); - }); - - if (placeholder) container.append(placeholder); - col.replaceChildren(container); - - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - container.append(embed); - observer.disconnect(); - }); - }, { threshold: 1 }); - observer.observe(container); -} - -/** - * Finds a YouTube link in a media column and converts it to a video embed. - * @param {HTMLElement} col - Media column to inspect and potentially transform - */ -function transformVideoLinks(col) { - const videoLink = [...col.children].find(isVideoLink); - if (videoLink) decorateVideo(col, videoLink.firstElementChild.href); -} /** * Builds a slide's media figure and caption from the row's media/caption columns. diff --git a/blocks/tabs/tabs.css b/blocks/tabs/tabs.css index e8bc3f5..8585ae0 100644 --- a/blocks/tabs/tabs.css +++ b/blocks/tabs/tabs.css @@ -1,50 +1,48 @@ .tabs .tabs-list { display: flex; + overflow-x: auto; padding: 0; background-color: var(--color-bg-light); - overflow-x: auto; -webkit-overflow-scrolling: touch; } .tabs .tabs-list button { - flex: 1 0 max-content; + flex: 1 0 min-content; margin: 0; border: 0; border-bottom: 8px solid var(--color-bg); padding: var(--space-m) var(--space-l); background-color: var(--color-bg-light); font-family: var(--heading-font-family); - font-size: var(--text-m); - font-weight: 700; - text-transform: uppercase; - white-space: nowrap; + line-height: 1.2; cursor: pointer; - transition: background-color 0.2s; + transition: box-shadow 0.2s; } .tabs .tabs-list button:hover { - background-color: var(--color-border); - box-shadow: inset 0 -5px 0 black; + box-shadow: inset 0 -5px 0 var(--color-bg-dark); } .tabs .tabs-list button[aria-selected='true'] { position: relative; - background-color: var(--color-bg); box-shadow: inset 0 -5px 0 var(--color-brand); + background-color: var(--color-bg); } .tabs .tabs-list button[aria-selected='true']::after { content: ''; position: absolute; - bottom: -8px; left: 50%; - transform: translateX(-50%); - border-inline: 8px solid transparent; + bottom: -8px; border-top: 8px solid var(--color-brand); + border-inline: 8px solid transparent; + transform: translateX(-50%); } -.tabs .tabs-list button p { - margin: 0; +@media (width >= 900px) { + .tabs .tabs-list button { + flex: 1 1 0; + } } .tabs .tabs-panel { @@ -56,6 +54,30 @@ display: none; } +.tabs .tabs-panel .media-wrapper { + position: relative; + overflow: hidden; + max-width: var(--content-width); + margin: auto; + aspect-ratio: 16 / 9; +} + +.tabs .tabs-panel .media-wrapper picture { + position: absolute; + inset: 0; +} + +.tabs .tabs-panel .media-wrapper img { + height: 100%; + width: 100%; + object-fit: cover; +} + +.tabs .tabs-panel .media-wrapper .video-embed { + height: 100%; + width: 100%; +} + @media (width >= 900px) { .tabs .tabs-panel { padding: var(--space-l); diff --git a/blocks/tabs/tabs.js b/blocks/tabs/tabs.js index 878d97d..56f9ffc 100644 --- a/blocks/tabs/tabs.js +++ b/blocks/tabs/tabs.js @@ -1,5 +1,25 @@ // eslint-disable-next-line import/no-unresolved import { toClassName } from '../../scripts/aem.js'; +import { transformVideoLinks } from '../../scripts/scripts.js'; + +/** + * Marks a tabpanel's media-only cells (picture, video, or video link) as `media-wrapper`, + * matching the columns block's media styling. + * @param {Element} tabpanel - The tabpanel whose cells to inspect + */ +function decorateMedia(tabpanel) { + [...tabpanel.children].forEach((cell) => { + transformVideoLinks(cell); + + const els = [...cell.children]; + const isMedia = els.length > 0 && els.every( + (el) => el.tagName === 'PICTURE' + || el.tagName === 'VIDEO' + || el.classList.contains('video-embed'), + ); + if (isMedia) cell.classList.add('media-wrapper'); + }); +} export default async function decorate(block) { // build tablist @@ -41,6 +61,7 @@ export default async function decorate(block) { }); tablist.append(button); tab.remove(); + decorateMedia(tabpanel); }); block.prepend(tablist); diff --git a/blocks/tabs/tabs_audit.md b/blocks/tabs/tabs_audit.md new file mode 100644 index 0000000..8c78071 --- /dev/null +++ b/blocks/tabs/tabs_audit.md @@ -0,0 +1,200 @@ +# `.tabs (.tabs.auth-track)` Full Spec + +Found on both pages you referenced. + +Note upfront: the page also has an unrelated `.tabs.tabs-vertical` in the global header mega-menu. It uses identical markup on both pages, byte-for-byte, but it is a different, separately built widget: a roving tab list for the "Business Segments" dropdown. It is out of scope for this analysis and is only noted here to avoid confusion with the content component below. + +Instances found: +- `Locomotive.html`: 1 instance, 2 tabs (`GBRf` / `Saudi Arabia`), each holding a `.multimedia` video gallery +- `Infrastructure.html`: 2 instances, first with 5 tabs and second with 4 tabs, containing mixed `.multimedia` video and `.texteditor` content + +Both pages use the same clientlib hash (`v2`), so the component version is identical across instances. + +This is built on AEM Core Components Tabs, extended with Slick carousel for horizontal overflow scrolling of the tab nav itself. + +## 1. HTML Structure + +```text +div.tabs[.section-padding*][.aem-GridColumn.aem-GridColumn--default--12] + div.tabs.auth-track[data-cmp-is="tabs"] + div.container + div.tabs__nav[tabindex=0][aria-orientation="horizontal"] + div.tabs__nav-item[.--active] + [id] [role="tab"] [type="button"] [tabindex="0"/"-1"] + [aria-controls=] [aria-selected="true"/"false"] + [data-cmp-hook-tabs="tab"] +

Label

+ div.tabs__content.select-text + div.tabs__content-item[role="tabpanel"][id=] + [tabindex="0"/"-1"] [aria-labelledby=] [aria-hidden="false"/"true"] + -> nested component: .media-youtube > .multimedia.auth-track OR .teaser... OR .texteditor... +``` + +Notes: +- `div.tabs__nav` becomes Slick `.slick-list` / `.slick-track`. +- `div.tabs__content.select-text` becomes a Slick fade carousel. +- Tab buttons have solid native semantics out of the box: `role="tab"`, `role="tabpanel"`, `aria-controls`, `aria-labelledby`, `aria-selected`, and roving `tabindex`. +- Markup is identical in structure across both pages and instances, which suggests a well-templated reusable component. + +### Structural quirk confirmed live + +JS runs `applyTabsAccessibilityFix()` which: +- forces `role="none"` onto the `

`-`

` inside each tab so the heading is not redundantly announced inside the tab button +- stamps `role="tablist"` onto Slick's generated `.slick-slide` wrapper divs +- adds `aria-posinset` and `aria-setsize` to each tab + +This is a post-hoc ARIA patch layered over Slick-generated markup and re-run on every `init`, `reInit`, `setPosition`, and `afterChange` event, rather than semantics being correct by construction. + +## 2. CSS + +Source: `tabs/v2` clientlib, 49 rules total + +### Base + +- `.tabs__nav` -> `display:flex`, fixed `height:78px` +- `.tabs__nav::before` uses a full-bleed `100vw` plus negative margin trick to draw a full-width line/background under the tab row +- `.tabs__nav-item` -> fixed `height:70px`, bottom border and right divider +- Active tabs render a CSS triangle via `::after` +- `.slick-arrow` is scoped two ways: + - `.tabs > .tabs__nav .slick-arrow` + - `.tabs .tabs__nav > .slick-list .slick-arrow` +- Arrows are circular `34x34px` and positioned at fixed `top:34px` +- Nested `.multimedia` blocks receive tabs-specific overrides, including zero left/right padding and adjusted Slick arrow positioning + +### `>= 1400px` + +- Tab items get `20px` left/right padding. + +### `<= 1399px` + +- Tab items get `min-width:140px` +- Tab items get `max-width:224px` +- Padding remains `20px` + +### `<= 992px` + +- Arrows shrink with `transform:scale(0.9)` +- A white glow via `box-shadow` softens clipped edges +- Nested `.multimedia` container padding is zeroed out + +Note: this arrow styling is effectively dead for `.tabs__nav`, because JS disables arrows below this breakpoint. It only affects nested `.multimedia` arrows. + +### `<= 767px` + +- Nested `.multimedia` nav and slide padding are forced to `0` +- `.tabs .slick-list` and `.tabs .slick-track` get `min-height:330px` to prevent collapse during fade transitions on short content + +### Coherence gap + +No CSS media query directly controls how many tabs are shown. That logic lives in JS through Slick's responsive config. + +Current breakpoint split: +- CSS: `1399px`, `992px`, `767px` +- JS: `992px`, `768px`, `492px` + +These are close, but not aligned. That creates pixel ranges where visual sizing assumes one density while Slick behavior uses another. + +## 3. JS Functionality + +Library: jQuery + Slick, plus a hand-rolled accessibility patch layer + +### Dual Slick instances + +Two separate Slick instances are kept in sync: + +- `.tabs__nav` + - `slidesToShow:5` + - `arrows:true` at `>= 993px` + - `slidesToShow:3`, `arrows:false` at `<= 992px` and `<= 768px` + - `slidesToShow:2`, `arrows:false` at `<= 492px` +- `.tabs__content` + - `slidesToShow:1` + - `fade:true` + - `arrows:false` + - `swipe:false` + +Clicking or keying a tab calls `slickGoTo` on the content carousel to match the nav carousel's active index. The two widgets are synchronized manually through helper logic. + +### Keyboard model + +- On the container, `keydown` intercepts: + - Left: `37` + - Right: `39` + - Home: `36` + - End: `35` +- The handler moves between `.tabs__nav-item` controls regardless of whether they are clipped in the overflow track. +- It both activates and focuses the target tab. + +This follows the ARIA APG tab-list pattern. Confirmed live: it correctly reaches and scrolls to tabs clipped out of view, including in the 5-tab instance at tablet width. + +### Panel switching + +Panel switching logic: +- deactivates all tabs with `aria-selected="false"` and `tabindex="-1"` +- activates the target tab with `aria-selected="true"` and `tabindex="0"` +- hides all panels with `aria-hidden="true"` and `.hide()` +- removes links and buttons in hidden panels from the tab order +- restores focusability for interactive children in the shown panel + +This is a solid manual approximation of `inert` behavior for browsers that do not support native `inert`. + +### Height sync + +After any tab or slide change, height logic recalculates `.tabs__content` to match the active panel's real height. + +Special case: +- if the active panel contains a `.multimedia` gallery, the code delegates to Slick's own `setPosition` instead of doing manual height math + +### Deep-linking + +- Clicking a tab rewrites the URL hash to that tab's id using `history.replaceState` +- No page jump or reload occurs +- On page load, if the current hash matches a tab id, that tab auto-activates +- The page then smooth-scrolls to it after a `1s` delay + +### Responsiveness after the fact + +- A debounced `window.resize` handler calls `slick("refresh")` on the nav if initialized +- A separate `orientationchange` handler does the same + +This is stronger than the `.multimedia` gallery implementation, which had no resize handling, but the `500ms` debounce and additional height-sync delays can create visible settle time after resize. + +### Confirmed overflow gap + +At about `800px` viewport width with 5 tabs: +- only 2-3 tabs are visible in the Slick track +- no arrows render because `arrows:false` is configured at that breakpoint +- no scrollbar, fade, dot, or chevron indicates overflow + +The only discovery path for remaining tabs is dragging the row. Keyboard users are fine. Pointer-only users get no visible affordance. + +## Verified Gaps + +- Overflow discoverability is poor at tablet widths with 4+ tabs. Hidden tabs can be clipped with no visual cue. +- CSS and JS breakpoint definitions do not share a single source of truth. +- ARIA is patched after construction instead of being authored correctly into the generated structure. +- Two independent Slick instances are manually synchronized, which increases desync risk. +- The `<= 992px` arrow shrink/glow rule is dead for `.tabs__nav` while arrows remain JS-disabled at that width. + +## Recommendations + +### DOM Structure + +- Author ARIA roles into the Slick markup at init time instead of reapplying `applyTabsAccessibilityFix()` across multiple events. +- Add a visible and programmatic overflow indicator when the tab row contains more slides than `slidesToShow`. +- Reconsider suppressing arrows below `993px`. That breakpoint is exactly where overflow becomes more likely. +- Standardize on one shared breakpoint set across CSS and JS. + +### CSS + +- Remove the dead `<= 992px` arrow rule for `.tabs__nav .slick-arrow`, or restore matching JS behavior so the CSS is not orphaned. +- Deduplicate the defensive double-selector used for Slick arrows unless the DOM variation is still necessary. +- Extract the `100vw` full-bleed trick in `.tabs__nav::before` into a shared utility if the same pattern exists elsewhere. +- Reevaluate min/max tab widths against real tab-count scenarios. With 5 tabs at `max-width:224px`, the nav row alone can demand roughly `1120px` before container padding. + +### JS / UX + +- Merge the two Slick instances into a single source of truth where feasible, or at minimum add guardrails to detect active-index desync. +- Add a visible overflow cue for pointer users at breakpoints where arrows are off but content still overflows. +- Reduce chained `setTimeout` delays in height sync, deep-link scroll, and resize refresh in favor of event-driven triggers. +- Consider skipping Slick nav initialization entirely when tab count is less than or equal to the breakpoint's visible slot count. A 2-tab instance does not need overflow carousel machinery. diff --git a/blocks/video/video.js b/blocks/video/video.js index 67f8196..9d8c81e 100644 --- a/blocks/video/video.js +++ b/blocks/video/video.js @@ -1,5 +1,4 @@ -import { decorateIcons } from '../../scripts/aem.js'; -import { loadCopy } from '../../scripts/scripts.js'; +import { loadCopy, createYouTubeEmbed, createPlaceholder } from '../../scripts/scripts.js'; /** * Returns the video provider name for a supported URL. @@ -12,25 +11,6 @@ function detectType(url) { return null; } -/** - * Builds a YouTube iframe. - * @param {string} url - the video page or short URL to parse - * @param {Object} copy - Localized UI strings - * @returns {HTMLIFrameElement|null} configured embed iframe - */ -export function createYouTubeEmbed(url, copy = {}) { - const { hostname, pathname, searchParams } = new URL(url); - const id = hostname === 'youtu.be' ? pathname.slice(1) : searchParams.get('v'); - if (!id) return null; - - const iframe = document.createElement('iframe'); - iframe.src = `https://www.youtube.com/embed/${id}`; - iframe.title = copy.youTubeVideo || 'YouTube video'; - iframe.setAttribute('allowfullscreen', ''); - iframe.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture'); - return iframe; -} - /** * Builds an embed iframe for the given provider type and URL. * @param {string} type - provider name returned by detectType @@ -43,42 +23,6 @@ function createEmbed(type, url, copy) { return null; } -/** - * Builds a thumbnail overlay with a play button. - * @param {HTMLElement|null} picture - thumbnail image element to display; returns null if absent - * @param {Function} onPlay - called when the user activates the play control - * @returns {HTMLElement|null} the placeholder figure - */ -export function createPlaceholder(picture, onPlay) { - if (!picture) return null; - - const figure = document.createElement('figure'); - figure.classList.add('placeholder'); - figure.setAttribute('role', 'button'); - figure.setAttribute('tabindex', '0'); - figure.append(picture); - - const icon = document.createElement('span'); - icon.className = 'icon icon-play'; - figure.append(icon); - decorateIcons(figure); - - function play() { - onPlay(); - figure.remove(); - } - - figure.addEventListener('click', play); - figure.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - play(); - } - }); - - return figure; -} - /** * Removes the block and its section wrapper from the DOM. * @param {HTMLElement} block - the block element to remove diff --git a/scripts/scripts.js b/scripts/scripts.js index fa94809..cc4f2db 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -153,6 +153,122 @@ export function isYouTubeHref(href) { } } +/** + * Builds a YouTube iframe. + * @param {string} url - the video page or short URL to parse + * @param {Object} copy - Localized UI strings + * @returns {HTMLIFrameElement|null} configured embed iframe + */ +export function createYouTubeEmbed(url, copy = {}) { + const { hostname, pathname, searchParams } = new URL(url); + const id = hostname === 'youtu.be' ? pathname.slice(1) : searchParams.get('v'); + if (!id) return null; + + const iframe = document.createElement('iframe'); + iframe.src = `https://www.youtube.com/embed/${id}`; + iframe.title = copy.youTubeVideo || 'YouTube video'; + iframe.setAttribute('allowfullscreen', ''); + iframe.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture'); + return iframe; +} + +/** + * Builds a thumbnail overlay with a play button. + * @param {HTMLElement|null} picture - thumbnail image element to display; returns null if absent + * @param {Function} onPlay - called when the user activates the play control + * @returns {HTMLElement|null} the placeholder figure + */ +export function createPlaceholder(picture, onPlay) { + if (!picture) return null; + + const figure = document.createElement('figure'); + figure.classList.add('placeholder'); + figure.setAttribute('role', 'button'); + figure.setAttribute('tabindex', '0'); + figure.append(picture); + + const icon = document.createElement('span'); + icon.className = 'icon icon-play'; + figure.append(icon); + decorateIcons(figure); + + function play() { + onPlay(); + figure.remove(); + } + + figure.addEventListener('click', play); + figure.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + play(); + } + }); + + return figure; +} + +/** + * Whether el is a paragraph containing a single YouTube link. + * @param {Element} el - Element to test + * @returns {boolean} + */ +export function isVideoLink(el) { + if (el.tagName !== 'P' || el.children.length !== 1) return false; + const a = el.firstElementChild; + return a.tagName === 'A' && isYouTubeHref(a.href); +} + +/** + * Transforms a media container with a YouTube link into a video embed. + * @param {HTMLElement} container - Container whose children are replaced with the embed + * @param {string} href - YouTube URL to embed + */ +export function decorateVideo(container, href) { + const embed = createYouTubeEmbed(href); + if (!embed) return; + + const videoContainer = document.createElement('div'); + videoContainer.className = 'video-embed'; + + const picture = container.querySelector('picture'); + const placeholder = createPlaceholder(picture, () => { + const src = new URL(embed.src); + src.searchParams.set('autoplay', 1); + embed.src = src.href; + if (!embed.isConnected) videoContainer.append(embed); + }); + + if (placeholder) videoContainer.append(placeholder); + container.replaceChildren(videoContainer); + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + videoContainer.append(embed); + observer.disconnect(); + }); + }, { rootMargin: '0px' }); + observer.observe(videoContainer); +} + +/** + * Finds a video link in a media container and converts it into a video embed. + * @param {HTMLElement} container - Container to inspect and potentially transform + */ +export function transformVideoLinks(container) { + const videoLinkEl = [...container.children].find(isVideoLink); + if (videoLinkEl) { + const nonVideo = [...container.children].filter((el) => el !== videoLinkEl); + const isPictureParagraph = (el) => el.tagName === 'P' + && el.children.length === 1 + && el.firstElementChild.tagName === 'PICTURE'; + if (nonVideo.every((el) => el.tagName === 'PICTURE' || el.tagName === 'VIDEO' || isPictureParagraph(el))) { + decorateVideo(container, videoLinkEl.firstElementChild.href); + } + } +} + /** * Turns standalone YouTube links into video blocks. * @param {Element} main The container element diff --git a/styles/styles.css b/styles/styles.css index 5d4cf4d..1329667 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -578,3 +578,40 @@ main .section.dark button.button.secondary:focus-visible { padding: var(--space-l) 0; } } + +/* SHARED VIDEO EMBEDS */ + +.video-embed { + position: relative; +} + +.video-embed iframe { + height: 100%; + width: 100%; + border: none; +} + +.video-embed .placeholder { + position: absolute; + inset: 0; + margin: 0; + cursor: pointer; +} + +.video-embed .placeholder img { + height: 100%; + width: 100%; + object-fit: cover; +} + +.video-embed .placeholder .icon { + position: absolute; + top: 50%; + left: 50%; + height: 80px; + width: 80px; + color: var(--color-white); + filter: drop-shadow(0 1px 5px rgb(0 0 0 / 60%)); + transform: translate(-50%, -50%); + pointer-events: none; +}