Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions blocks/article-cards/article-cards.css
Original file line number Diff line number Diff line change
@@ -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);
}
85 changes: 85 additions & 0 deletions blocks/article-cards/article-cards.js
Original file line number Diff line number Diff line change
@@ -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 <title> 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);
}
}
41 changes: 41 additions & 0 deletions blocks/category-tabs/category-tabs.css
Original file line number Diff line number Diff line change
@@ -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);
}
9 changes: 9 additions & 0 deletions blocks/category-tabs/category-tabs.js
Original file line number Diff line number Diff line change
@@ -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');
}
70 changes: 70 additions & 0 deletions test/blocks/article-cards/article-cards.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});