diff --git a/blocks/v2-truck-lineup/v2-truck-lineup.css b/blocks/v2-truck-lineup/v2-truck-lineup.css new file mode 100644 index 000000000..646420cd1 --- /dev/null +++ b/blocks/v2-truck-lineup/v2-truck-lineup.css @@ -0,0 +1,308 @@ +:root { + --truck-lineup-img-width: 60%; + --truck-lineup-navigation-icon: 10px; +} + +@keyframes truck-entry { + 0% { + transform: translateX(100%); + opacity: 0; + } + + 25% { + opacity: 1; + } + + 100% { + transform: translateX(0); + } +} + +/* Full width block */ +main .section.v2-truck-lineup-container .v2-truck-lineup-wrapper { + margin: 0; + padding: 0; + width: 100%; + max-width: none; +} + +main .section.v2-truck-lineup-container { + padding: 0; +} + +/* End Full width block */ + +.v2-truck-lineup { + display: flex; + flex-direction: column; +} + +.v2-truck-lineup__images-container, +.v2-truck-lineup__description-container { + display: flex; + flex-flow: row nowrap; + padding-left: 0; + margin: 0; +} + +.v2-truck-lineup__description-container { + order: 2; + overflow: hidden; +} + +.v2-truck-lineup__images-container { + align-items: flex-end; + overflow: scroll hidden; + scroll-behavior: smooth; + scroll-snap-type: x mandatory; + scrollbar-width: none; +} + +.v2-truck-lineup__images-container::-webkit-scrollbar { + display: none; +} + +.v2-truck-lineup__image-item { + flex: none; + scroll-snap-align: center; + text-align: center; + width: var(--truck-lineup-img-width); +} + +.v2-truck-lineup__image-item picture { + display: block; + animation-duration: 1s; + animation-delay: 0.5s; + animation-name: truck-entry; + animation-timing-function: ease-in; +} + +.v2-truck-lineup__image-item:first-child { + margin-left: 50vw; +} + +.v2-truck-lineup__images-container::after { + content: ''; + display: block; + flex: 0 0 50vw; +} + +.v2-truck-lineup__image-item img { + aspect-ratio: 16 / 9; + max-height: 80vh; + width: auto; +} + +.v2-truck-lineup__content { + margin: 0 auto 32px; + text-align: center; + width: 90%; +} + +.v2-truck-lineup__desc-item { + color: var(--text-color); + flex: none; + opacity: 0; + width: 100%; + + /* fadeout */ + transition: opacity 0.3s cubic-bezier(0, 0, 0, 1); +} + +.v2-truck-lineup__desc-item.active { + opacity: 1; + transition: opacity 0.5s cubic-bezier(0, 0, 0, 1) 0.5s; +} + +.v2-truck-lineup__text { + margin: 0 auto; + max-width: 400px; + text-wrap: balance; +} + +.v2-truck-lineup__title { + font-family: var(--ff-headline-medium); + font-size: 45px; + letter-spacing: -0.9px; + line-height: 117%; + margin: 0; +} + +.v2-truck-lineup__buttons-container { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: center; + margin-top: 32px; +} + +.v2-truck-lineup__buttons-container a.button, +.v2-truck-lineup__buttons-container .button-container { + margin: 0; +} + +/* Navigation */ +.v2-truck-lineup__navigation::-webkit-scrollbar { + display: none; +} + +ul.v2-truck-lineup__navigation { + display: flex; + flex-flow: row nowrap; + list-style: none; + margin: 32px 0; + order: 0; + overflow: auto; + padding: 2px 32px; + position: relative; +} + +.v2-truck-lineup__navigation-line { + background: var(--c-primary-black); + bottom: 3px; + height: 3px; + left: 0; + margin: 0; + position: absolute; + transition: all var(--duration-small) var(--easing-standard); + width: 0; +} + +.v2-truck-lineup__navigation::before, +.v2-truck-lineup__navigation::after { + content: ''; + margin: auto; +} + +.v2-truck-lineup__navigation-item { + border-bottom: 1px solid var(--c-primary-black); +} + +.v2-truck-lineup__navigation-item.active button, +.v2-truck-lineup__navigation-item button:hover { + --color-icon: var(--c-accent-copper); + + color: var(--c-primary-black); +} + +/* stylelint-disable-next-line no-descending-specificity */ +.v2-truck-lineup__navigation-item button { + --color-icon: var(--c-secondary-steel); + + background: 0 0; + border: none; + color: var(--c-secondary-steel); + display: flex; + font-family: var(--ff-subheadings-medium); + font-size: var(--headline-5-font-size); + line-height: var(--headline-5-line-height); + margin: 0 0 0 20px; + padding: 14px 0; + text-wrap: nowrap; +} + +.v2-truck-lineup__navigation-item:first-child button { + margin-left: 0; +} + +.v2-truck-lineup__navigation-item .icon { + display: inline-flex; + height: var(--truck-lineup-navigation-icon); + width: var(--truck-lineup-navigation-icon); +} + +/* Arrow controls */ +.v2-truck-lineup__slider-wrapper { + order: 1; + position: relative; +} + +.v2-truck-lineup__arrow-controls { + display: none; + margin: 0; + opacity: 0; + transition: opacity var(--duration-small) var(--easing-standard); +} + +.v2-truck-lineup__slider-wrapper:hover .v2-truck-lineup__arrow-controls { + opacity: 1; +} + +.v2-truck-lineup__arrow-controls li { + align-items: center; + display: flex; + height: 100%; + left: 10%; + position: absolute; + top: 0; +} + +.v2-truck-lineup__arrow-controls li:last-child { + left: auto; + right: 10%; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.v2-truck-lineup__arrow-controls button { + background-color: var(--c-primary-white); + border: 1px solid var(--c-primary-black); + color: var(--c-primary-black); + font-size: 0; + line-height: 0; + margin: 0; + padding: 16px; + position: relative; +} + +.v2-truck-lineup__arrow-controls button:hover { + background-color: #F1F1F1; + border-color: #A7A8A9; +} + +.v2-truck-lineup__arrow-controls button:active { + background-color: #E1DFDD; + border-color: #D9D9D9; +} + +.v2-truck-lineup__arrow-controls button:focus { + outline: 2px solid var(--border-focus); +} + +@media screen and (min-width: 744px) { + .v2-truck-lineup__navigation-item button { + font-size: var(--headline-4-font-size); + line-height: var(--headline-4-line-height); + } + + .v2-truck-lineup__arrow-controls { + display: block; + } +} + +@media screen and (min-width: 1200px) { + :root { + --truck-lineup-img-width: 60%; + --truck-lineup-navigation-icon: 15px; + } + + .v2-truck-lineup__content { + max-width: var(--truck-lineup-img-width); + } + + .v2-truck-lineup__content .default-content-wrapper { + align-items: flex-start; + display: flex; + justify-content: space-between; + } + + .v2-truck-lineup__text { + flex-basis: 60%; + margin: 0; + text-align: left; + } + + .v2-truck-lineup__buttons-container { + flex-direction: row; + margin-top: 0; + } +} diff --git a/blocks/v2-truck-lineup/v2-truck-lineup.js b/blocks/v2-truck-lineup/v2-truck-lineup.js new file mode 100644 index 000000000..a2f974800 --- /dev/null +++ b/blocks/v2-truck-lineup/v2-truck-lineup.js @@ -0,0 +1,251 @@ +import { decorateIcons } from '../../scripts/lib-franklin.js'; +import { createElement } from '../../scripts/common.js'; + +const blockName = 'v2-truck-lineup'; + +function stripEmptyTags(main, child) { + if (child !== main && child.innerHTML.trim() === '') { + const parent = child.parentNode; + child.remove(); + stripEmptyTags(main, parent); + } +} + +const moveNavigationLine = (navigationLine, activeTab, tabNavigation) => { + const { x: navigationX } = tabNavigation.getBoundingClientRect(); + const { x, width } = activeTab.getBoundingClientRect(); + Object.assign(navigationLine.style, { + left: `${x + tabNavigation.scrollLeft - navigationX}px`, + width: `${width}px`, + }); +}; + +function buildTabNavigation(tabItems, clickHandler) { + const tabNavigation = createElement('ul', { classes: `${blockName}__navigation` }); + const navigationLine = createElement('li', { classes: `${blockName}__navigation-line` }); + let timeout; + + [...tabItems].forEach((tabItem, i) => { + const listItem = createElement('li', { classes: `${blockName}__navigation-item` }); + const button = createElement('button'); + button.addEventListener('click', () => clickHandler(i)); + button.addEventListener('mouseover', (e) => { + clearTimeout(timeout); + moveNavigationLine(navigationLine, e.currentTarget, tabNavigation); + }); + + button.addEventListener('mouseout', () => { + timeout = setTimeout(() => { + const activeItem = document.querySelector(`.${blockName}__navigation-item.active button`); + moveNavigationLine(navigationLine, activeItem, tabNavigation); + }, 600); + }); + + const tabContent = tabItem.querySelector(':scope > div'); + const icon = tabContent.dataset.truckCarouselIcon; + const svgIcon = icon ? `` : ''; + button.innerHTML = `${tabContent.dataset.truckCarousel}${svgIcon}`; + listItem.append(button); + tabNavigation.append(listItem); + }); + + tabNavigation.append(navigationLine); + + return tabNavigation; +} + +const updateActiveItem = (index) => { + const images = document.querySelector(`.${blockName}__images-container`); + const descriptions = document.querySelector(`.${blockName}__description-container`); + const navigation = document.querySelector(`.${blockName}__navigation`); + const navigationLine = document.querySelector(`.${blockName}__navigation-line`); + + [images, descriptions, navigation].forEach((c) => c.querySelectorAll('.active').forEach((i) => i.classList.remove('active'))); + images.children[index].classList.add('active'); + descriptions.children[index].classList.add('active'); + navigation.children[index].classList.add('active'); + + const activeNavigationItem = navigation.children[index].querySelector('button'); + moveNavigationLine(navigationLine, activeNavigationItem, navigation); + + // Center navigation item + const navigationActiveItem = navigation.querySelector('.active'); + + if (navigation && navigationActiveItem) { + const { clientWidth: itemWidth, offsetLeft } = navigationActiveItem; + // Calculate the scroll position to center the active item + const scrollPosition = offsetLeft - (navigation.clientWidth - itemWidth) / 2; + navigation.scrollTo({ + left: scrollPosition, + behavior: 'smooth', + }); + } + + // Update description position + const descriptionWidth = descriptions.offsetWidth; + + descriptions.scrollTo({ + left: descriptionWidth * index, + behavior: 'smooth', + }); +}; + +const listenScroll = (carousel) => { + const elements = carousel.querySelectorAll(':scope > *'); + + const io = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if ( + entry.isIntersecting + && entry.intersectionRatio >= 0.9 + ) { + const activeItem = entry.target; + const currentIndex = [...activeItem.parentNode.children].indexOf(activeItem); + updateActiveItem(currentIndex); + } + }); + }, { + root: carousel, + threshold: 0.9, + }); + + elements.forEach((el) => { + io.observe(el); + }); +}; + +const setCarouselPosition = (carousel, index) => { + const firstEl = carousel.firstElementChild; + const scrollOffset = firstEl.getBoundingClientRect().width; + const style = window.getComputedStyle(firstEl); + const marginleft = parseFloat(style.marginLeft); + + carousel.scrollTo({ + left: index * scrollOffset + marginleft, + behavior: 'smooth', + }); +}; + +const createArrowControls = (carousel) => { + function scroll(direction) { + const activeItem = carousel.querySelector(`.${blockName}__image-item.active`); + let index = [...activeItem.parentNode.children].indexOf(activeItem); + if (direction === 'left') { + index -= 1; + if (index === -1) { + index = carousel.childElementCount; + } + } else { + index += 1; + if (index > carousel.childElementCount - 1) { + index = 0; + } + } + + setCarouselPosition(carousel, index); + } + + const arrowControls = createElement('ul', { classes: [`${blockName}__arrow-controls`] }); + const arrows = document.createRange().createContextualFragment(` +
  • + +
  • +
  • + +
  • + `); + arrowControls.append(...arrows.children); + carousel.insertAdjacentElement('beforebegin', arrowControls); + const [prevButton, nextButton] = arrowControls.querySelectorAll(':scope button'); + prevButton.addEventListener('click', () => scroll('left')); + nextButton.addEventListener('click', () => scroll('right')); +}; + +export default async function decorate(block) { + const descriptionContainer = block.querySelector(':scope > div'); + descriptionContainer.classList.add(`${blockName}__description-container`); + + const tabItems = block.querySelectorAll(':scope > div > div'); + + const imagesWrapper = createElement('div', { classes: `${blockName}__slider-wrapper` }); + const imagesContainer = createElement('div', { classes: `${blockName}__images-container` }); + descriptionContainer.parentNode.prepend(imagesWrapper); + imagesWrapper.appendChild(imagesContainer); + + const tabNavigation = buildTabNavigation(tabItems, (index) => { + setCarouselPosition(imagesContainer, index); + }); + await decorateIcons(tabNavigation); + + // Arrows + createArrowControls(imagesContainer); + + descriptionContainer.parentNode.append(tabNavigation); + + tabItems.forEach((tabItem) => { + tabItem.classList.add(`${blockName}__desc-item`); + const tabContent = tabItem.querySelector(':scope > div'); + const headings = tabContent.querySelectorAll('h1, h2, h3, h4, h5, h6'); + [...headings].forEach((heading) => heading.classList.add(`${blockName}__title`)); + + // create div for image and append inside image div container + const picture = tabItem.querySelector('picture'); + const imageItem = createElement('div', { classes: `${blockName}__image-item` }); + imageItem.appendChild(picture); + imagesContainer.appendChild(imageItem); + + // remove empty tags + tabContent.querySelectorAll('p, div').forEach((item) => { + stripEmptyTags(tabContent, item); + }); + + const descriptions = tabContent.querySelectorAll('p:not(.button-container)'); + [...descriptions].forEach((description) => description.classList.add(`${blockName}__description`)); + + // Wrap text in container + const textContainer = createElement('div', { classes: `${blockName}__text` }); + const text = tabContent.querySelector('.default-content-wrapper')?.querySelectorAll(':scope > *:not(.button-container)'); + if (text) { + const parentTextContainer = text[0].parentNode; + textContainer.append(...text); + parentTextContainer.appendChild(textContainer); + } + + // Wrap links in container + const buttonContainer = createElement('div', { classes: `${blockName}__buttons-container` }); + const buttons = tabContent.querySelectorAll('.button-container'); + + buttons.forEach((bt, i) => { + const buttonLink = bt.firstElementChild; + + if (i > 0) { + buttonLink.classList.remove('button--primary'); + buttonLink.classList.add('button--secondary'); + } + }); + + if (buttons.length) { + const parentButtonContainer = buttons[0].parentNode; + buttonContainer.append(...buttons); + parentButtonContainer.appendChild(buttonContainer); + } + }); + + // update the button indicator on scroll + listenScroll(imagesContainer); + + // Update text position + navigation line when page is resized + window.addEventListener('resize', () => { + const activeItem = imagesContainer.querySelector(`.${blockName}__image-item.active`); + const index = [...activeItem.parentNode.children].indexOf(activeItem); + updateActiveItem(index); + }); +} diff --git a/icons/flash.svg b/icons/flash.svg index 6aad8e58a..843f9bbf8 100644 --- a/icons/flash.svg +++ b/icons/flash.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/scripts/scripts.js b/scripts/scripts.js index da6193acc..7106cf9d8 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -16,6 +16,7 @@ import { toCamelCase, toClassName, loadScript, + getHref, } from './lib-franklin.js'; import { @@ -149,6 +150,51 @@ export function findAndCreateImageLink(node) { } }); } +/** + * Returns a picture element with webp and fallbacks / allow multiple src paths for every breakpoint + * @param {string} src Default image URL (if no src is passed to breakpoints object) + * @param {boolean} eager load image eager + * @param {Array} breakpoints breakpoints and corresponding params (eg. src, width, media) + */ +export function createCustomOptimizedPicture(src, alt = '', eager = false, breakpoints = [{ media: '(min-width: 400px)', width: '2000' }, { width: '750' }]) { + const url = new URL(src, getHref()); + const picture = document.createElement('picture'); + let { pathname } = url; + const ext = pathname.substring(pathname.lastIndexOf('.') + 1); + + breakpoints.forEach((br) => { + // custom src path in breakpoint + if (br.src) { + const customUrl = new URL(br.src, getHref()); + pathname = customUrl.pathname; + } + + const source = document.createElement('source'); + if (br.media) source.setAttribute('media', br.media); + source.setAttribute('type', 'image/webp'); + source.setAttribute('srcset', `${pathname}?width=${br.width}&format=webply&optimize=medium`); + picture.appendChild(source); + }); + + // fallback + breakpoints.forEach((br, j) => { + if (j < breakpoints.length - 1) { + const source = document.createElement('source'); + if (br.media) source.setAttribute('media', br.media); + source.setAttribute('srcset', `${pathname}?width=${br.width}&format=${ext}&optimize=medium`); + picture.appendChild(source); + } else { + const image = document.createElement('img'); + image.setAttribute('loading', eager ? 'eager' : 'lazy'); + image.setAttribute('alt', alt); + picture.appendChild(image); + image.setAttribute('src', `${pathname}?width=${br.width}&format=${ext}&optimize=medium`); + } + }); + + return picture; +} + /** * Builds hero block and prepends to main in a new section. * @param {Element} main The container element @@ -285,9 +331,7 @@ const createInpageNavigation = (main) => { return navItems; }; -function buildInpageNavigationBlock(main) { - const inapgeClassName = 'v2-inpage-navigation'; - +function buildInpageNavigationBlock(main, classname) { const items = createInpageNavigation(main); if (items.length > 0) { @@ -297,11 +341,11 @@ function buildInpageNavigationBlock(main) { overflow: 'hidden', }); - section.append(buildBlock(inapgeClassName, { elems: items })); + section.append(buildBlock(classname, { elems: items })); // insert in second position, assumption is that Hero should be first main.insertBefore(section, main.children[1]); - decorateBlock(section.querySelector(`.${inapgeClassName}`)); + decorateBlock(section.querySelector(`.${classname}`)); } } @@ -318,11 +362,11 @@ function buildTabbedBlock(main, classname) { const tabItems = []; const mainChildren = [...main.querySelectorAll(':scope > div')]; - mainChildren.forEach((section) => { + mainChildren.forEach((section, i2) => { const isCarousel = section.dataset.carousel; if (!isCarousel) return; - nextElement = mainChildren[i + 1]; + nextElement = mainChildren[i2 + 1]; const tabContent = createElement('div', { classes: `${classname}__item` }); tabContent.dataset.carousel = section.dataset.carousel; tabContent.innerHTML = section.innerHTML; @@ -359,8 +403,10 @@ export function decorateMain(main, head) { decorateBlocks(main); decorateLinks(main); + // Truck carousel + buildTruckLineupBlock(main, 'v2-truck-lineup'); // Inpage navigation - buildInpageNavigationBlock(main); + buildInpageNavigationBlock(main, 'v2-inpage-navigation'); // V2 tabbed carousel buildTabbedBlock(main, 'v2-tabbed-carousel'); } @@ -408,6 +454,7 @@ async function loadEager(doc) { await getPlaceholders(); } + /** * Loads everything that doesn't need to be delayed. * @param {Element} doc The container element @@ -605,6 +652,55 @@ allLinks.forEach((link) => { link.innerText = selectedText; }); +function createTruckLineupSection(tabItems, classname) { + const tabSection = createElement('div', { classes: 'section' }); + tabSection.dataset.sectionStatus = 'initialized'; + const wrapper = createElement('div'); + tabSection.append(wrapper); + const tabBlock = buildBlock(classname, [tabItems]); + wrapper.append(tabBlock); + return tabSection; +} + +function buildTruckLineupBlock(main, classname) { + const tabItems = []; + let nextElement; + + const mainChildren = [...main.querySelectorAll(':scope > div')]; + mainChildren.forEach((section, i2) => { + const isTruckCarousel = section.dataset.truckCarousel; + if (!isTruckCarousel) return; + + // save carousel position + nextElement = mainChildren[i2 + 1]; + const sectionMeta = section.dataset.truckCarousel; + + const tabContent = createElement('div', { classes: `${classname}__content` }); + tabContent.dataset.truckCarousel = sectionMeta; + if (section.dataset.truckCarouselIcon) { + tabContent.dataset.truckCarouselIcon = section.dataset.truckCarouselIcon; + } + + tabContent.innerHTML = section.innerHTML; + const image = tabContent.querySelector('p > picture'); + tabContent.prepend(image); + + tabItems.push(tabContent); + section.remove(); + }); + + if (tabItems.length > 0) { + const tabbedCarouselSection = createTruckLineupSection(tabItems, classname); + if (nextElement) { // if we saved a position push the carousel in that position if not + main.insertBefore(tabbedCarouselSection, nextElement); + } else { + main.append(tabbedCarouselSection); + } + decorateIcons(tabbedCarouselSection); + decorateBlock(tabbedCarouselSection.querySelector(`.${classname}`)); + } +} + /* REDESING CLASS CHECK */ if (getMetadata('style') === 'redesign-v2') { document.querySelector('html').classList.add('redesign-v2'); diff --git a/styles/styles.css b/styles/styles.css index a1f4abe50..916fa868b 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -62,6 +62,9 @@ --button-tertiary-white-pressed: #e1dfdd; --button-tertiary-white-disabled: var(--c-primary-white); + /* Focus */ + --border-focus: red; + /* Font family */ /* Alternate fonts */ @@ -847,6 +850,11 @@ main .section.responsive-title h1 { --inpage-navigation-height: 0; } +.redesign-v2 body { + font-size: var(--body-1-font-size); + line-height: var(--body-1-line-height); +} + /* stylelint-disable-next-line no-descending-specificity */ :where(.redesign-v2) h1, :where(.redesign-v2) h2, :where(.redesign-v2) h3, :where(.redesign-v2) h4, :where(.redesign-v2) h5, :where(.redesign-v2) h6, @@ -902,6 +910,13 @@ main .section.responsive-title h1 { /* ICONS STYLES - END */ /* REDESIGN Buttons */ +/* stylelint-disable-next-line no-descending-specificity */ +.redesign-v2 a.button:any-link, +.redesign-v2 button { + font-weight: normal; +} + +/* stylelint-disable-next-line no-descending-specificity */ .redesign-v2 a.button, .redesign-v2 button.button { align-items: center;