diff --git a/blocks/carousel/carousel.css b/blocks/carousel/carousel.css new file mode 100644 index 0000000..27c26be --- /dev/null +++ b/blocks/carousel/carousel.css @@ -0,0 +1,194 @@ +/* homepage feature carousel: one slide visible at a time on a white band, + image + heading/body/CTA, with arrows, dots and a counter below */ +.carousel { + position: relative; + background-color: var(--conti-white); +} + +.carousel-viewport { + overflow: hidden; +} + +.carousel-track { + display: flex; + transition: transform 0.5s ease-in-out; +} + +.carousel-slide { + display: flex; + flex: 0 0 100%; + flex-direction: column; + width: 100%; + box-sizing: border-box; +} + +.carousel-media { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 4 / 3; + background-color: var(--conti-lightest-grey); + line-height: 0; +} + +.carousel-media picture, +.carousel-media img { + display: block; + width: 100%; + height: 100%; +} + +.carousel-media img { + object-fit: contain; +} + +.carousel-content { + padding: 24px 0 0; +} + +.carousel-content h3 { + margin-top: 0; +} + +.carousel-content p { + color: var(--conti-black); + font-size: var(--body-font-size-m); +} + +.carousel-content .carousel-cta-wrapper { + margin-top: 20px; +} + +.carousel-cta { + display: inline-flex; + align-items: center; + color: var(--conti-black); + font-family: var(--heading-font-family); + font-size: 12px; + font-weight: 700; + letter-spacing: 1.25px; + text-decoration: underline; + text-decoration-color: var(--conti-yellow); + text-decoration-thickness: 2px; + text-underline-offset: 4px; + text-transform: uppercase; +} + +.carousel-cta:hover, +.carousel-cta:focus-visible { + color: var(--conti-dark-yellow); +} + +.carousel-cta::after { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + margin-inline-start: 8px; + border-inline-end: 2px solid currentcolor; + border-block-end: 2px solid currentcolor; + transform: translateY(-1px) rotate(-45deg); +} + +.carousel-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 24px 0 0; +} + +.carousel-arrow { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid var(--conti-grey); + border-radius: 50%; + background-color: var(--conti-white); + padding: 0; + cursor: pointer; + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.carousel-arrow:hover, +.carousel-arrow:focus-visible { + border-color: var(--conti-black); +} + +.carousel-arrow::before { + content: ''; + width: 8px; + height: 8px; + border-inline-start: 2px solid var(--conti-black); + border-block-end: 2px solid var(--conti-black); +} + +.carousel-prev::before { + transform: rotate(45deg) translate(1px, -1px); +} + +.carousel-next::before { + transform: rotate(-135deg) translate(1px, -1px); +} + +.carousel-dots { + display: flex; + align-items: center; + gap: 10px; +} + +.carousel-dot { + width: 10px; + height: 10px; + border: 0; + border-radius: 50%; + background-color: var(--conti-grey); + padding: 0; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.2s ease; +} + +.carousel-dot:hover, +.carousel-dot:focus-visible { + background-color: var(--conti-dark-yellow); +} + +.carousel-dot[aria-current="true"] { + background-color: var(--conti-yellow); + transform: scale(1.2); +} + +.carousel-counter { + color: var(--conti-black); + font-family: var(--heading-font-family); + font-size: 12px; + font-weight: 700; + letter-spacing: 1.25px; +} + +@media (width >= 900px) { + .carousel-slide { + flex-direction: row-reverse; + align-items: center; + gap: 48px; + } + + .carousel-media { + flex: 1 1 auto; + aspect-ratio: 16 / 9; + } + + .carousel-content { + flex: 0 0 340px; + width: 340px; + padding: 0; + } + + .carousel-nav { + justify-content: flex-start; + padding: 32px 0 0; + } +} diff --git a/blocks/carousel/carousel.js b/blocks/carousel/carousel.js new file mode 100644 index 0000000..b6db895 --- /dev/null +++ b/blocks/carousel/carousel.js @@ -0,0 +1,127 @@ +import { createOptimizedPicture } from '../../scripts/aem.js'; + +/** + * Feature carousel: one slide visible at a time, each pairing an image with + * a heading, body copy and a CTA link. Reads slides from authored rows + * (image cell + text cell) and builds a track plus prev/next arrows, dot + * indicators and an "N of X" counter. Wraps around. Autoplay is opt-in via + * a `carousel (autoplay)` variant and pauses on hover/focus. + * @param {Element} block the carousel block + */ +export default function decorate(block) { + const rows = [...block.children]; + const total = rows.length; + if (total === 0) return; + + const slides = rows.map((row, i) => { + const [imageCell, textCell] = [...row.children]; + + const media = document.createElement('div'); + media.className = 'carousel-media'; + if (imageCell) { + while (imageCell.firstChild) media.append(imageCell.firstChild); + media.querySelectorAll('picture > img').forEach((img) => { + img.closest('picture').replaceWith(createOptimizedPicture(img.src, img.alt)); + }); + } + + const content = document.createElement('div'); + content.className = 'carousel-content'; + if (textCell) { + while (textCell.firstChild) content.append(textCell.firstChild); + } + // the CTA is a paragraph whose entire text is a single link + const ctaPara = [...content.children].find((el) => { + const a = el.tagName === 'P' ? el.querySelector('a') : null; + return a && el.textContent.trim() === a.textContent.trim(); + }); + if (ctaPara) { + ctaPara.classList.add('carousel-cta-wrapper'); + ctaPara.querySelector('a').classList.add('carousel-cta'); + } + + const slide = document.createElement('div'); + slide.className = 'carousel-slide'; + slide.setAttribute('role', 'group'); + slide.setAttribute('aria-roledescription', 'slide'); + slide.setAttribute('aria-label', `${i + 1} of ${total}`); + slide.append(media, content); + return slide; + }); + + const track = document.createElement('div'); + track.className = 'carousel-track'; + track.append(...slides); + + const viewport = document.createElement('div'); + viewport.className = 'carousel-viewport'; + viewport.append(track); + + const prevBtn = document.createElement('button'); + prevBtn.type = 'button'; + prevBtn.className = 'carousel-arrow carousel-prev'; + prevBtn.setAttribute('aria-label', 'Previous slide'); + + const nextBtn = document.createElement('button'); + nextBtn.type = 'button'; + nextBtn.className = 'carousel-arrow carousel-next'; + nextBtn.setAttribute('aria-label', 'Next slide'); + + const dots = document.createElement('div'); + dots.className = 'carousel-dots'; + const dotButtons = rows.map((row, i) => { + const dot = document.createElement('button'); + dot.type = 'button'; + dot.className = 'carousel-dot'; + dot.setAttribute('aria-label', `Show slide ${i + 1} of ${total}`); + dots.append(dot); + return dot; + }); + + const counter = document.createElement('div'); + counter.className = 'carousel-counter'; + counter.setAttribute('aria-live', 'polite'); + counter.setAttribute('aria-atomic', 'true'); + + const nav = document.createElement('div'); + nav.className = 'carousel-nav'; + nav.append(prevBtn, dots, nextBtn, counter); + + let current = 0; + const goTo = (index) => { + current = ((index % total) + total) % total; + track.style.transform = `translateX(-${current * 100}%)`; + slides.forEach((slide, i) => { + slide.setAttribute('aria-hidden', i === current ? 'false' : 'true'); + slide.toggleAttribute('inert', i !== current); + }); + dotButtons.forEach((dot, i) => dot.setAttribute('aria-current', i === current ? 'true' : 'false')); + counter.textContent = `${current + 1} of ${total}`; + }; + + prevBtn.addEventListener('click', () => goTo(current - 1)); + nextBtn.addEventListener('click', () => goTo(current + 1)); + dotButtons.forEach((dot, i) => dot.addEventListener('click', () => goTo(i))); + block.addEventListener('keydown', (e) => { + if (e.key === 'ArrowLeft') goTo(current - 1); + if (e.key === 'ArrowRight') goTo(current + 1); + }); + + // gentle optional autoplay, default off: add an `autoplay` variant to enable + if (block.classList.contains('autoplay')) { + let timer; + const start = () => { timer = window.setInterval(() => goTo(current + 1), 6000); }; + const stop = () => window.clearInterval(timer); + block.addEventListener('mouseenter', stop); + block.addEventListener('mouseleave', start); + block.addEventListener('focusin', stop); + block.addEventListener('focusout', start); + start(); + } + + goTo(0); + block.setAttribute('role', 'region'); + block.setAttribute('aria-roledescription', 'carousel'); + block.setAttribute('aria-label', 'Featured highlights'); + block.replaceChildren(viewport, nav); +} diff --git a/blocks/perfect-fit/perfect-fit.css b/blocks/perfect-fit/perfect-fit.css index ec71c78..82aaeb4 100644 --- a/blocks/perfect-fit/perfect-fit.css +++ b/blocks/perfect-fit/perfect-fit.css @@ -32,6 +32,21 @@ main .section.perfect-fit-container { padding: 0; } +.perfect-fit-item { + display: block; + border: 0; + background: none; + padding: 0; + color: inherit; + font: inherit; + cursor: pointer; +} + +.perfect-fit-item:focus-visible { + outline: 2px solid var(--conti-yellow); + outline-offset: 4px; +} + .perfect-fit-items p { display: flex; align-items: center; @@ -61,3 +76,143 @@ main .section.perfect-fit-container { padding: 14px 32px; } } + +/* tire-finder modal, opened from the perfect-fit items above */ +.perfect-fit-overlay { + position: fixed; + inset: 0; + z-index: 999; + display: flex; + align-items: center; + justify-content: center; + background-color: rgb(0 0 0 / 60%); + padding: 16px; +} + +.perfect-fit-overlay[hidden] { + display: none; +} + +body.perfect-fit-modal-open { + overflow: hidden; +} + +.perfect-fit-dialog { + position: relative; + box-sizing: border-box; + width: 100%; + max-width: 560px; + max-height: calc(100vh - 32px); + overflow-y: auto; + background-color: var(--conti-white); + padding: 32px 24px 24px; + text-align: left; +} + +.perfect-fit-close { + position: absolute; + top: 12px; + right: 12px; + border: 0; + background: none; + padding: 8px; + color: var(--conti-black); + font-size: 24px; + line-height: 1; + cursor: pointer; +} + +.perfect-fit-modal-title { + margin: 0 0 20px; + color: var(--conti-black); + font-family: var(--heading-font-family); + font-size: var(--heading-font-size-s); + font-weight: 300; +} + +.perfect-fit-tablist { + display: flex; + gap: 24px; + border-bottom: 1px solid var(--conti-grey); + margin-bottom: 20px; +} + +.perfect-fit-tab { + border: 0; + border-bottom: 2px solid transparent; + background: none; + margin-bottom: -1px; + padding: 0 0 12px; + color: var(--conti-darkest-grey); + font-family: var(--heading-font-family); + font-size: 12px; + font-weight: 700; + letter-spacing: 1.25px; + text-transform: uppercase; + cursor: pointer; +} + +.perfect-fit-tab[aria-selected="true"] { + border-bottom-color: var(--conti-yellow); + color: var(--conti-black); +} + +.perfect-fit-close:focus-visible, +.perfect-fit-tab:focus-visible { + outline: 2px solid var(--conti-yellow); + outline-offset: 2px; +} + +.perfect-fit-form { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +.perfect-fit-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.perfect-fit-field label { + color: var(--conti-black); + font-size: var(--body-font-size-xs); + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.perfect-fit-field select, +.perfect-fit-field input { + box-sizing: border-box; + width: 100%; + border: 1px solid var(--conti-grey); + border-radius: 0; + background-color: var(--conti-white); + padding: 10px 12px; + color: var(--conti-black); + font-family: var(--body-font-family); + font-size: var(--body-font-size-s); +} + +.perfect-fit-field select:focus-visible, +.perfect-fit-field input:focus-visible { + outline: 2px solid var(--conti-yellow); + outline-offset: 2px; +} + +.perfect-fit-form .button { + margin-top: 8px; + justify-self: start; +} + +@media (width >= 700px) { + .perfect-fit-form { + grid-template-columns: repeat(2, 1fr); + } + + .perfect-fit-form .button { + grid-column: 1 / -1; + } +} diff --git a/blocks/perfect-fit/perfect-fit.js b/blocks/perfect-fit/perfect-fit.js index 13d8382..a3def8e 100644 --- a/blocks/perfect-fit/perfect-fit.js +++ b/blocks/perfect-fit/perfect-fit.js @@ -1,7 +1,257 @@ +/** + * "Find your perfect fit:" bar plus its tire-finder modal. The bar's three + * items open the modal on a matching tab (By Vehicle / By Tire Size / By + * Plate); each tab is a simple, non-functional sample search form. + */ + +const TABS = [ + { id: 'vehicle', label: 'By Vehicle' }, + { id: 'tire-size', label: 'By Tire Size' }, + { id: 'plate', label: 'By Plate' }, +]; + +function range(start, end, step = 1) { + const values = []; + for (let n = start; n <= end; n += step) values.push(n); + return values; +} + +const YEARS = range(2015, 2026).reverse(); +const MAKES = ['Chevrolet', 'Ford', 'Toyota', 'BMW', 'Honda']; +const WIDTHS = range(185, 315, 10); +const ASPECT_RATIOS = range(40, 75, 5); +const RIM_DIAMETERS = range(15, 22); +const STATES = ['California', 'Florida', 'Illinois', 'New York', 'Ohio', 'Texas']; + +/** + * Builds a labelled `` field. + * @returns {{ wrapper: Element, field: Element }} + */ +function createField(tag, id, labelText) { + const wrapper = document.createElement('div'); + wrapper.className = 'perfect-fit-field'; + const label = document.createElement('label'); + label.setAttribute('for', id); + label.textContent = labelText; + const field = document.createElement(tag); + field.id = id; + field.name = id; + wrapper.append(label, field); + return { wrapper, field }; +} + +/** Fills a `