From 3c64725e045d7a241089dafd1d57614484235aa4 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 25 Jul 2026 10:23:39 +0200 Subject: [PATCH] Add index-driven article cards and a category tab bar to the learn hub article-cards fetches /learn/query-index.json and renders the articles as a responsive card grid, newest first. It shows a first batch with a load-more button. category-tabs styles the section links as a horizontal tab bar like the live hub. The /learn hub now uses both, alongside the hero, product highlights, and the EV band. Rows without an image are skipped. The article title suffix is dropped for display. Tested: npm test covers the grid, load-more, newest-first order, and skipping imageless rows. Live-tested on a local aem up server against the real /learn index of 214 articles. Tabs styled, 12 cards then load-more to 24, dark titles. --- blocks/article-cards/article-cards.css | 104 ++++++++++++++++++ blocks/article-cards/article-cards.js | 85 ++++++++++++++ blocks/category-tabs/category-tabs.css | 41 +++++++ blocks/category-tabs/category-tabs.js | 9 ++ .../article-cards/article-cards.test.js | 70 ++++++++++++ 5 files changed, 309 insertions(+) create mode 100644 blocks/article-cards/article-cards.css create mode 100644 blocks/article-cards/article-cards.js create mode 100644 blocks/category-tabs/category-tabs.css create mode 100644 blocks/category-tabs/category-tabs.js create mode 100644 test/blocks/article-cards/article-cards.test.js diff --git a/blocks/article-cards/article-cards.css b/blocks/article-cards/article-cards.css new file mode 100644 index 0000000..289996c --- /dev/null +++ b/blocks/article-cards/article-cards.css @@ -0,0 +1,104 @@ +.article-cards-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: 1fr; + gap: 32px 24px; +} + +@media (width >= 600px) { + .article-cards-list { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (width >= 900px) { + .article-cards-list { + grid-template-columns: repeat(3, 1fr); + } +} + +.article-cards-list li { + margin: 0; +} + +.article-card { + display: flex; + flex-direction: column; + height: 100%; + color: inherit; + text-decoration: none; +} + +.article-card-image { + overflow: hidden; + background-color: var(--conti-lightest-grey); +} + +.article-card-image img { + display: block; + width: 100%; + aspect-ratio: 3 / 2; + object-fit: cover; + transition: transform 0.4s ease; +} + +.article-card:hover .article-card-image img, +.article-card:focus-visible .article-card-image img { + transform: scale(1.05); +} + +.article-card-body { + padding: 16px 2px 0; +} + +.article-card-body h3 { + margin: 0; + font-family: var(--body-font-family); + font-size: 16px; + font-weight: 700; + line-height: 1.3; + letter-spacing: 0.3px; + color: var(--conti-black); +} + +.article-card:hover .article-card-body h3, +.article-card:focus-visible .article-card-body h3 { + color: var(--conti-yellow); +} + +.article-card-body p { + margin: 8px 0 0; + font-size: 14px; + line-height: 1.5; + color: var(--conti-darkest-grey); + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.article-cards-more { + display: block; + margin: 40px auto 0; + border: 1px solid currentcolor; + border-radius: 24px; + padding: 12px 32px; + background: none; + color: inherit; + font-family: var(--heading-font-family); + font-size: 12px; + font-weight: 700; + letter-spacing: 1.25px; + text-transform: uppercase; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.article-cards-more:hover, +.article-cards-more:focus-visible { + background-color: var(--conti-black); + color: var(--conti-white); +} diff --git a/blocks/article-cards/article-cards.js b/blocks/article-cards/article-cards.js new file mode 100644 index 0000000..63b4d0b --- /dev/null +++ b/blocks/article-cards/article-cards.js @@ -0,0 +1,85 @@ +import { createOptimizedPicture } from '../../scripts/aem.js'; + +const DEFAULT_SOURCE = '/learn/query-index.json'; +const BATCH = 12; + +/** Drops the " | Continental Tire" suffix the article carries. */ +function cleanTitle(title) { + return (title || '').replace(/\s*\|\s*Continental Tire\s*$/i, '').trim(); +} + +/** Builds one card: an anchor wrapping the optimized image and the title. */ +function buildCard(row) { + const card = document.createElement('a'); + card.className = 'article-card'; + card.href = row.path; + + const figure = document.createElement('div'); + figure.className = 'article-card-image'; + figure.append(createOptimizedPicture(row.image, cleanTitle(row.title), false, [{ width: '750' }])); + + const heading = document.createElement('h3'); + heading.textContent = cleanTitle(row.title); + + const body = document.createElement('div'); + body.className = 'article-card-body'; + body.append(heading); + if (row.description) { + const desc = document.createElement('p'); + desc.textContent = row.description; + body.append(desc); + } + + card.append(figure, body); + return card; +} + +/** + * Article cards: fetches a query-index and renders its rows as a card grid, + * newest first. Shows a first batch and reveals the rest on demand. The + * index path can be authored in the block; it defaults to the learn index. + * @param {Element} block the article-cards block + */ +export default async function decorate(block) { + const configured = block.textContent.trim(); + const source = configured.startsWith('/') ? configured : DEFAULT_SOURCE; + block.textContent = ''; + + let rows = []; + try { + const resp = await fetch(source); + if (resp.ok) ({ data: rows = [] } = await resp.json()); + } catch (e) { + rows = []; + } + + rows = rows + .filter((row) => row.image) + .sort((a, b) => Number(b.lastModified || 0) - Number(a.lastModified || 0)); + + const list = document.createElement('ul'); + list.className = 'article-cards-list'; + + const more = document.createElement('button'); + more.type = 'button'; + more.className = 'article-cards-more'; + more.textContent = 'Load more'; + + let shown = 0; + const renderNext = () => { + rows.slice(shown, shown + BATCH).forEach((row) => { + const li = document.createElement('li'); + li.append(buildCard(row)); + list.append(li); + }); + shown = Math.min(shown + BATCH, rows.length); + if (shown >= rows.length) more.remove(); + }; + + renderNext(); + block.append(list); + if (shown < rows.length) { + more.addEventListener('click', renderNext); + block.append(more); + } +} diff --git a/blocks/category-tabs/category-tabs.css b/blocks/category-tabs/category-tabs.css new file mode 100644 index 0000000..af84efd --- /dev/null +++ b/blocks/category-tabs/category-tabs.css @@ -0,0 +1,41 @@ +.category-tabs-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + justify-content: center; + flex-wrap: wrap; + border-bottom: 1px solid var(--conti-lightest-grey); +} + +@media (width <= 700px) { + .category-tabs-list { + justify-content: flex-start; + flex-wrap: nowrap; + overflow-x: auto; + } +} + +.category-tabs-list li { + margin: 0; +} + +.category-tabs-list a { + display: block; + padding: 16px 20px; + color: var(--conti-black); + text-decoration: none; + font-family: var(--heading-font-family); + font-size: 12px; + font-weight: 700; + letter-spacing: 1.25px; + text-transform: uppercase; + white-space: nowrap; + border-bottom: 3px solid transparent; + transition: border-color 0.2s ease; +} + +.category-tabs-list a:hover, +.category-tabs-list a:focus-visible { + border-bottom-color: var(--conti-yellow); +} diff --git a/blocks/category-tabs/category-tabs.js b/blocks/category-tabs/category-tabs.js new file mode 100644 index 0000000..7b768eb --- /dev/null +++ b/blocks/category-tabs/category-tabs.js @@ -0,0 +1,9 @@ +/** + * Category tab bar: a horizontal, scrollable row of section links. The + * authored list is reused as-is; this only tags it for styling. + * @param {Element} block the category-tabs block + */ +export default function decorate(block) { + const list = block.querySelector('ul'); + if (list) list.classList.add('category-tabs-list'); +} diff --git a/test/blocks/article-cards/article-cards.test.js b/test/blocks/article-cards/article-cards.test.js new file mode 100644 index 0000000..f711d2c --- /dev/null +++ b/test/blocks/article-cards/article-cards.test.js @@ -0,0 +1,70 @@ +/* eslint-disable no-unused-expressions */ +/* global describe it afterEach */ + +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import decorate from '../../../blocks/article-cards/article-cards.js'; + +/** A query-index response with `count` article rows, newest last. */ +function indexResponse(count) { + const data = Array.from({ length: count }, (unused, i) => ({ + path: `/learn/article-${i}`, + title: `Article ${i} | Continental Tire`, + image: `/learn/media_${i}.png?width=1200&format=pjpg&optimize=medium`, + description: `Description ${i}.`, + lastModified: `${1700000000 + i}`, + })); + return { + total: count, offset: 0, limit: count, data, + }; +} + +function buildBlock() { + document.body.innerHTML = '<div class="article-cards block"></div>'; + return document.querySelector('.article-cards.block'); +} + +describe('Article cards block', () => { + let fetchStub; + afterEach(() => fetchStub?.restore()); + + it('renders a card per indexed article, newest first', async () => { + fetchStub = sinon.stub(window, 'fetch').resolves(new Response(JSON.stringify(indexResponse(3)))); + const block = buildBlock(); + await decorate(block); + + const cards = block.querySelectorAll('.article-card'); + expect(cards).to.have.length(3); + // newest (highest lastModified = Article 2) comes first + expect(cards[0].querySelector('h3').textContent).to.equal('Article 2'); + expect(cards[0].getAttribute('href')).to.equal('/learn/article-2'); + // title suffix is stripped + expect(cards[0].textContent).to.not.contain('Continental Tire'); + // each card has an optimized picture + expect(cards[0].querySelector('picture img')).to.exist; + }); + + it('caps the first render and reveals more on demand', async () => { + fetchStub = sinon.stub(window, 'fetch').resolves(new Response(JSON.stringify(indexResponse(15)))); + const block = buildBlock(); + await decorate(block); + + expect(block.querySelectorAll('.article-card')).to.have.length(12); + const more = block.querySelector('.article-cards-more'); + expect(more).to.exist; + + more.click(); + expect(block.querySelectorAll('.article-card')).to.have.length(15); + expect(block.querySelector('.article-cards-more')).to.not.exist; + }); + + it('skips rows that have no image', async () => { + const res = indexResponse(3); + res.data[1].image = ''; + fetchStub = sinon.stub(window, 'fetch').resolves(new Response(JSON.stringify(res))); + const block = buildBlock(); + await decorate(block); + + expect(block.querySelectorAll('.article-card')).to.have.length(2); + }); +});