From 9370b6e2ed4daaf9c24fcff83c47c3b0b76b99ba Mon Sep 17 00:00:00 2001 From: fkakatie Date: Thu, 25 Jun 2026 03:06:38 -0600 Subject: [PATCH] feat: branded cards --- blocks/cards/cards.css | 116 ++++++++++++++++++++++++++++++++++++++--- blocks/cards/cards.js | 79 +++++++++++++++++++++++++--- 2 files changed, 180 insertions(+), 15 deletions(-) diff --git a/blocks/cards/cards.css b/blocks/cards/cards.css index b080623..b52792d 100644 --- a/blocks/cards/cards.css +++ b/blocks/cards/cards.css @@ -1,27 +1,129 @@ +.cards { + display: flex; + justify-content: center; +} + .cards > ul { list-style: none; display: grid; - grid-template-columns: repeat(auto-fill, minmax(257px, 1fr)); + flex: 1; + grid-template-columns: 1fr; + justify-items: center; gap: var(--space-m); margin: 0; padding: 0; } +@media (width >= 600px) { + .cards.cols-2 > ul, + .cards.cols-4 > ul, + .cards.cols-6 > ul { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (width >= 900px) { + .cards.cols-3 > ul, + .cards.cols-6 > ul { + grid-template-columns: repeat(3, 1fr); + } + + .cards.cols-4 > ul { + grid-template-columns: repeat(4, 1fr); + } +} + .cards > ul > li { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; border: 1px solid var(--color-border); background-color: var(--color-bg); } -.cards .cards-card-body { - margin: var(--space-s); +.cards li.linked { + position: relative; + cursor: pointer; + transition: box-shadow 0.2s; +} + +.cards li.linked a::before { + content: ''; + position: absolute; + inset: 0; +} + +.cards li.linked:hover { + box-shadow: var(--shadow-elevated); +} + +/* media wrapper */ +.cards .media-wrapper { + position: relative; + overflow: hidden; + height: 200px; + border: 1px solid var(--color-border); + border-bottom: 0; } -.cards .cards-card-image { - line-height: 0; +.cards .media-wrapper picture { + position: absolute; + inset: 0; + box-sizing: border-box; } -.cards > ul > li img { +.cards .media-wrapper img { + height: 100%; width: 100%; - aspect-ratio: 4 / 3; object-fit: cover; } + +@media (width >= 600px) { + .cards .media-wrapper { + height: unset; + aspect-ratio: 4 / 3; + } +} + +/* body wrapper */ +.cards .body-wrapper { + flex: 1; + padding: var(--space-m); +} + +/* footer */ +.cards footer { + padding: var(--space-m); + padding-top: 0; +} + +.cards footer .button { + width: 100%; +} + +.cards footer a[href]:not(.button) { + color: inherit; + font-family: var(--heading-font-family); + text-transform: uppercase; +} + +.cards footer a[href]:not(.button)::after { + content: ''; + display: inline-block; + height: 1em; + width: 1em; + margin-left: 1ch; + background-color: currentcolor; + mask-image: url('/icons/chevron.svg'); + mask-repeat: no-repeat; + mask-size: contain; + transform: rotate(-90deg); + vertical-align: middle; +} + +@media (width >= 900px) { + .cards footer .button { + width: unset; + } +} diff --git a/blocks/cards/cards.js b/blocks/cards/cards.js index 4e6ec93..921ffc7 100644 --- a/blocks/cards/cards.js +++ b/blocks/cards/cards.js @@ -1,17 +1,80 @@ -import { createOptimizedPicture } from '../../scripts/aem.js'; +import { createOptimizedPicture, decorateIcons } from '../../scripts/aem.js'; + +/** + * Returns the largest factor of the block's row count between 1 and 6. + * @param {Element} block The cards block element + * @returns {number} Grid column count to apply as a cols-N class + */ +function getGridFactor(block) { + const rows = block.children.length; + for (let n = 6; n >= 2; n -= 1) { + if (rows % n === 0) return n; + } + return rows === 1 ? 1 : 3; +} + +/** + * Moves a trailing CTA out of .body-wrapper into a card footer. + * @param {HTMLElement} card The card
  • element being decorated + */ +function extractButtons(card) { + const body = card.querySelector('.body-wrapper'); + if (!body) return; + const last = body.lastElementChild; + if (!last) return; + + const isButtonWrapper = last.classList.contains('button-wrapper'); + const isLoneLink = last.tagName === 'P' + && last.querySelectorAll('a[href]').length === 1 + && last.textContent.trim() === last.querySelector('a[href]').textContent.trim(); + + if (!isButtonWrapper && !isLoneLink) return; + const footer = document.createElement('footer'); + footer.append(last); + card.append(footer); +} + +/** + * Marks a card as fully clickable when it contains exactly one link. + * @param {HTMLElement} card The card
  • element being decorated + */ +function linkCard(card) { + const links = [...card.querySelectorAll('a[href]')]; + if (links.length !== 1) return; + card.classList.add('linked'); +} + +/** + * Replaces all images in the block with optimized picture elements. + * @param {Element} block The cards block element + */ +function optimizeImages(block) { + block.querySelectorAll('.media-wrapper picture > img').forEach((img) => { + img.closest('picture').replaceWith(createOptimizedPicture(img.src, img.alt, false, [{ width: '750' }])); + }); +} export default function decorate(block) { - /* change to ul, li */ + if (![...block.classList].some((c) => c.startsWith('cols-'))) { + block.classList.add(`cols-${getGridFactor(block)}`); + } + const ul = document.createElement('ul'); - [...block.children].forEach((row) => { + [...block.children].forEach((card) => { const li = document.createElement('li'); - while (row.firstElementChild) li.append(row.firstElementChild); - [...li.children].forEach((div) => { - if (div.children.length === 1 && div.querySelector('picture')) div.className = 'cards-card-image'; - else div.className = 'cards-card-body'; + [...card.children].forEach((cell) => { + const els = [...cell.children]; + const isMedia = els.length > 0 && els.every((el) => el.tagName === 'PICTURE' || el.tagName === 'VIDEO'); + cell.classList.add(isMedia ? 'media-wrapper' : 'body-wrapper'); + li.append(cell); }); + extractButtons(li); + linkCard(li); ul.append(li); }); - ul.querySelectorAll('picture > img').forEach((img) => img.closest('picture').replaceWith(createOptimizedPicture(img.src, img.alt, false, [{ width: '750' }]))); + block.replaceChildren(ul); + + optimizeImages(block); + decorateIcons(block); }