From ef9becf756b418afeae818226af3f545e8a73675 Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Fri, 29 Sep 2023 12:43:11 +0200 Subject: [PATCH] Career testimonials: create testimonials carousel block (#71) * Career testimonials: create testimonials carousel block Fixes #17 --- blocks/career-carousel/career-carousel.css | 148 +++++++++++ blocks/career-carousel/career-carousel.js | 144 +++++++++++ icons/angle-left-blue.svg | 1 + icons/angle-right-blue.svg | 2 +- .../career-carousel.plain.html | 6 + .../career-carousel/career-carousel.test.js | 232 ++++++++++++++++++ 6 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 blocks/career-carousel/career-carousel.css create mode 100644 blocks/career-carousel/career-carousel.js create mode 100644 icons/angle-left-blue.svg create mode 100644 test/blocks/career-carousel/career-carousel.plain.html create mode 100644 test/blocks/career-carousel/career-carousel.test.js diff --git a/blocks/career-carousel/career-carousel.css b/blocks/career-carousel/career-carousel.css new file mode 100644 index 00000000..87cdbea7 --- /dev/null +++ b/blocks/career-carousel/career-carousel.css @@ -0,0 +1,148 @@ +.career-carousel { + --card-width: 16rem; +} + +.career-slider { + display: flex; + flex-direction: column; + margin-left: -1rem; + width: calc((var(--card-width) + 3rem) * 4); + overflow: hidden; +} + +.career-slides { + display: flex; + overflow-x: scroll; + scroll-behavior: smooth; + + /* We want to hide the scroll bar */ + -ms-overflow-style: none; /* for Internet Explorer, Edge */ + scrollbar-width: none; /* for Firefox */ +} + +/* Hide the scroll bar */ +.career-slides::-webkit-scrollbar { + /* for Chrome, Safari and Opera */ + display: none; +} + +.career-carousel .career-card { + flex-shrink: 0; + box-shadow: #3c465040 0 2px 12px 0; + margin: 0.5rem; + padding: 1rem; + width: var(--card-width); + transition: all 0.15s ease-in-out; +} + +.career-carousel .career-card:hover { + box-shadow: #3c465066 0 4px 16px 0; +} + +.career-carousel .career-card a { + display: inline-flex; + flex-direction: column; + text-decoration: none; +} + +.career-carousel .career-card picture { + align-self: center; + width: 14.375rem; + margin: 0 0 1.375rem; +} + +.career-carousel .career-card picture img { + height: 16rem; + width: 14.375rem; +} + +.career-carousel .career-card blockquote { + color: var(--primary); + font-size: var(--body-font-size-l); + font-style: normal; + line-height: 29px; + margin: 0 0 19px; + max-height: 9.375rem; + overflow: hidden; + text-indent: 0; + + /* Show ellipsis on multiline text */ + display: -webkit-box; + -webkit-line-clamp: 5; /* number of lines to show */ + -webkit-box-orient: vertical; +} + +.career-carousel .career-card .career-card-bqc { + height: 9.375rem; + margin-bottom: 0.75rem; +} + +.career-carousel .career-card h6 { + color: var(--light-black); + font-size: var( --heading-font-size-mxs); + text-transform: none; + margin-bottom: 6px; +} + +.career-carousel .career-card p { + color: var(--light-black); + font-size: var(--body-font-size-s); + height: 2.5rem; + line-height: 21.6px; + margin-bottom: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.career-carousel .career-card button { + background-color: unset; + color: var(--secondary); + font-family: var(--body-font-family); + font-size: var(--body-font-size-m); + font-weight: var(--font-weight-medium); + justify-content: left; + height: auto; + padding: 0; + margin-block: 1rem; +} + +.career-carousel .career-card button:hover { + text-decoration: underline; +} + +.career-carousel .career-card button .icon-angle-right-blue { + height: 14px; + width: 10px; + margin-left: 6px; + margin-top: 2px; +} + +.career-carousel .career-slides-navbar { + display: flex; +} + +.career-carousel .career-slides-nav { + display: inline-flex; + align-items: center; + margin-top: 3.125rem; + margin-bottom: 2.5rem; + margin-inline: auto; +} + +.career-carousel .career-slides-nav span { + width: 6px; + height: 6px; + margin: 0 3px; + border-radius: 50%; + background: #0003; +} + +.career-carousel .career-slides-nav span.active-nav { + background: var(--link-color); +} + +.career-carousel .career-slides-nav .btn-angle { + height: 1.5rem; + width: 1rem; + margin-inline: 0.5rem; +} diff --git a/blocks/career-carousel/career-carousel.js b/blocks/career-carousel/career-carousel.js new file mode 100644 index 00000000..d829b8ad --- /dev/null +++ b/blocks/career-carousel/career-carousel.js @@ -0,0 +1,144 @@ +import { createOptimizedPicture } from '../../scripts/lib-franklin.js'; +import { fetchIndex } from '../../scripts/scripts.js'; + +export function filterIncompleteEntries(json) { + return json.data.filter((e) => e.image !== '' && e['career-quote'] !== '0' && e['career-jobtitle'] !== '0'); +} + +export function scrollToCard(idx, card, precedingCard, slides, span, doc) { + const rect = card.getBoundingClientRect(); + + // set the style on the active button + const buttons = doc.querySelectorAll('.career-slides-nav span.active-nav'); + buttons.forEach((b) => b.classList.remove('active-nav')); + span.classList.add('active-nav'); + + // Compute the gap to add to the location + let gap = 0; + if (precedingCard) { + const prevRect = precedingCard.getBoundingClientRect(); + gap = rect.x - (prevRect.x + prevRect.width); + } + + slides.scrollTo(idx * (rect.width + gap), slides.scrollHeight); +} + +export function scrollToAdjacent(spans, slideDivs, slides, next, doc) { + const curActive = spans.findIndex((s) => s.classList.contains('active-nav')); + if (curActive === -1) { + return; + } + + // compute the next active element, wrapping around on over- or underflow. + const newActive = (curActive + (next ? 1 : -1) + spans.length) % spans.length; + if (slideDivs.length <= newActive) { + return; + } + + scrollToCard( + newActive, + slideDivs[newActive], + newActive > 0 ? slideDivs[newActive - 1] : null, + slides, + spans[newActive], + doc, + ); +} + +export default async function decorate(block) { + const json = await fetchIndex('query-index', 'career-testimonials'); + const data = filterIncompleteEntries(json); + + const careerSlider = document.createElement('div'); + careerSlider.classList.add('career-slider'); + + const careerSlides = document.createElement('div'); + careerSlides.classList.add('career-slides'); + + const slideDivs = []; + for (let i = 0; i < data.length; i += 1) { + const div = document.createElement('div'); + div.classList.add('career-card'); + const a = document.createElement('a'); + a.href = data[i].path; + const pic = createOptimizedPicture(data[i].image, data[i].pagename); + a.append(pic); + + const bqc = document.createElement('div'); + bqc.classList.add('career-card-bqc'); + const bq = document.createElement('blockquote'); + bq.textContent = data[i]['career-quote']; + bqc.append(bq); + a.append(bqc); + + const nm = document.createElement('h6'); + nm.textContent = data[i].pagename; + a.append(nm); + + const role = document.createElement('p'); + role.textContent = data[i]['career-jobtitle']; + a.append(role); + + const link = document.createElement('button'); + link.textContent = 'Read More '; // TODO + const arrow = document.createElement('img'); + arrow.src = '/icons/angle-right-blue.svg'; + arrow.alt = 'Go to testimonial'; + arrow.classList.add('icon-angle-right-blue'); + link.append(arrow); + a.append(link); + div.append(a); + + careerSlides.append(div); + slideDivs.push(div); + } + careerSlider.append(careerSlides); + + const navBar = document.createElement('div'); + navBar.classList.add('career-slides-navbar'); + const navButtons = document.createElement('div'); + navButtons.classList.add('career-slides-nav'); + + const buttons = []; + for (let i = 0; i < data.length; i += 1) { + const prevDiv = i > 0 ? slideDivs[i - 1] : null; + + const s = document.createElement('span'); + s.classList.toggle('active-nav', i === 0); + s.tabIndex = '-1'; + s.onclick = () => scrollToCard(i, slideDivs[i], prevDiv, careerSlides, s, document); + navButtons.append(s); + + buttons.push(s); + } + const la = document.createElement('img'); + la.src = '/icons/angle-left-blue.svg'; + la.alt = 'Previous person card'; + la.classList.add('btn-angle'); + la.onclick = () => scrollToAdjacent(buttons, slideDivs, careerSlides, false, document); + navButtons.prepend(la); + + const ra = document.createElement('img'); + ra.src = '/icons/angle-right-blue.svg'; + ra.alt = 'Next person card'; + ra.classList.add('btn-angle'); + ra.onclick = () => scrollToAdjacent(buttons, slideDivs, careerSlides, true, document); + navButtons.append(ra); + navBar.append(navButtons); + + block.append(careerSlider); + block.append(navBar); + + document.onkeydown = (e) => { + switch (e.keyCode) { + case 37: + la.onclick(); + break; + case 39: + ra.onclick(); + break; + default: + // do nothing + } + }; +} diff --git a/icons/angle-left-blue.svg b/icons/angle-left-blue.svg new file mode 100644 index 00000000..c23f720c --- /dev/null +++ b/icons/angle-left-blue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/angle-right-blue.svg b/icons/angle-right-blue.svg index 7a43cbf8..165c9049 100644 --- a/icons/angle-right-blue.svg +++ b/icons/angle-right-blue.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/test/blocks/career-carousel/career-carousel.plain.html b/test/blocks/career-carousel/career-carousel.plain.html new file mode 100644 index 00000000..5c843622 --- /dev/null +++ b/test/blocks/career-carousel/career-carousel.plain.html @@ -0,0 +1,6 @@ +
+ +
+
+ +
\ No newline at end of file diff --git a/test/blocks/career-carousel/career-carousel.test.js b/test/blocks/career-carousel/career-carousel.test.js new file mode 100644 index 00000000..e4b221cc --- /dev/null +++ b/test/blocks/career-carousel/career-carousel.test.js @@ -0,0 +1,232 @@ +/* eslint-disable no-unused-expressions */ +/* global describe before it */ + +import { expect } from '@esm-bundle/chai'; +import { readFile } from '@web/test-runner-commands'; + +const scripts = {}; + +document.write(await readFile({ path: './career-carousel.plain.html' })); + +describe('Career Carousel', () => { + before(async () => { + const mod = await import('../../../blocks/career-carousel/career-carousel.js'); + Object + .keys(mod) + .forEach((func) => { + scripts[func] = mod[func]; + }); + }); + + it('Filter incomplete career data entries', () => { + const json = { + foo: 'bar', + data: [ + { + label: 'l1', image: '', 'career-quote': '0', 'career-jobtitle': '0', + }, + { + label: 'l2', image: 'img', 'career-quote': 'quote 2', 'career-jobtitle': '0', + }, + { + label: 'l3', image: '', 'career-quote': 'quote 3', 'career-jobtitle': 'title', + }, + { + label: 'l4', image: 'image 4', 'career-quote': 'quote 4', 'career-jobtitle': 'title 4', + }, + { + label: 'l5', image: 'image 5', 'career-quote': 'quote 5', 'career-jobtitle': 'title 5', + }, + ], + }; + + const data = scripts.filterIncompleteEntries(json); + expect(data.length).to.equal(2); + expect(data[0].label).to.equal('l4'); + expect(data[0].image).to.equal('image 4'); + expect(data[0]['career-quote']).to.equal('quote 4'); + expect(data[0]['career-jobtitle']).to.equal('title 4'); + expect(data[1].label).to.equal('l5'); + expect(data[1].image).to.equal('image 5'); + expect(data[1]['career-quote']).to.equal('quote 5'); + expect(data[1]['career-jobtitle']).to.equal('title 5'); + }); + + function mockClassList(el) { + el.classList = {}; + el.classList.els = []; + el.classList.add = (...s) => el.classList.els.push(...s); + el.classList.contains = (s) => el.classList.els.includes(s); + el.classList.remove = (s) => { el.classList.els = el.classList.els.filter((e) => e !== s); }; + } + + it('Can scroll to a specific card', () => { + const card = {}; + card.getBoundingClientRect = () => ({ x: 348, width: 100 }); + + const prevCard = {}; + prevCard.getBoundingClientRect = () => ({ x: 232, width: 100 }); + + const span1 = {}; + mockClassList(span1); + span1.classList.add('blah'); + const span2 = {}; + mockClassList(span2); + span2.classList.add('active-nav', 'blah'); + + const doc = {}; + doc.querySelectorAll = () => [span1, span2]; + + const span = {}; + mockClassList(span); + + let scrolledX; + let scrolledY; + const slides = {}; + slides.scrollHeight = 259; + slides.scrollTo = (x, y) => { + scrolledX = x; + scrolledY = y; + }; + + scripts.scrollToCard(3, card, prevCard, slides, span, doc); + + expect(scrolledX).to.equal(348); + expect(scrolledY).to.equal(259); + expect(span.classList.els).to.deep.equal(['active-nav']); + expect(span1.classList.els).to.deep.equal(['blah']); + expect(span2.classList.els).to.deep.equal(['blah']); + }); + + it('Can scroll to the first card', () => { + const card = {}; + card.getBoundingClientRect = () => ({ x: 25, width: 100 }); + + const span = {}; + mockClassList(span); + + const doc = {}; + doc.querySelectorAll = () => []; + + let scrolledX; + let scrolledY; + const slides = {}; + slides.scrollHeight = 765; + slides.scrollTo = (x, y) => { + scrolledX = x; + scrolledY = y; + }; + + scripts.scrollToCard(0, card, null, slides, span, doc); + + expect(scrolledX).to.equal(0); + expect(scrolledY).to.equal(765); + expect(span.classList.els).to.deep.equal(['active-nav']); + }); + + function setupAdjacentTest(activeSpan) { + const doc = {}; + doc.querySelectorAll = () => []; + + const span1 = {}; + mockClassList(span1); + const span2 = {}; + mockClassList(span2); + const span3 = {}; + mockClassList(span3); + const spans = [span1, span2, span3]; + spans[activeSpan].classList.add('active-nav'); + + const div1 = {}; + div1.getBoundingClientRect = () => ({ x: 0, width: 100 }); + const div2 = {}; + div2.getBoundingClientRect = () => ({ x: 110, width: 100 }); + const div3 = {}; + div3.getBoundingClientRect = () => ({ x: 220, width: 100 }); + const divs = [div1, div2, div3]; + + return { + doc, + spans, + divs, + }; + } + + it('Can scroll to next adjacent card', () => { + const { doc, spans, divs } = setupAdjacentTest(0); + + let scrolledX; + let scrolledY; + const slides = {}; + slides.scrollHeight = 256; + slides.scrollTo = (x, y) => { + scrolledX = x; + scrolledY = y; + }; + + scripts.scrollToAdjacent(spans, divs, slides, true, doc); + + expect(scrolledX).to.equal(110); + expect(scrolledY).to.equal(256); + }); + + it('Can scroll to next adjacent card, rotating to start', () => { + const { doc, spans, divs } = setupAdjacentTest(2); + + let scrolledX; + let scrolledY; + const slides = {}; + slides.scrollHeight = 256; + slides.scrollTo = (x, y) => { + scrolledX = x; + scrolledY = y; + }; + + scripts.scrollToAdjacent(spans, divs, slides, true, doc); + + expect(scrolledX).to.equal(0); + expect(scrolledY).to.equal(256); + }); + + it('Can scroll to previous adjacent card, rotating to end', () => { + const { doc, spans, divs } = setupAdjacentTest(0); + + let scrolledX; + let scrolledY; + const slides = {}; + slides.scrollHeight = 256; + slides.scrollTo = (x, y) => { + scrolledX = x; + scrolledY = y; + }; + + scripts.scrollToAdjacent(spans, divs, slides, false, doc); + + expect(scrolledX).to.equal(220); + expect(scrolledY).to.equal(256); + }); + + it('Can scroll to an adacent card', () => { + const span1 = {}; + mockClassList(span1); + const span2 = {}; + mockClassList(span2); + const span3 = {}; + mockClassList(span3); + const spans = [span1, span2, span3]; + + let scrolledX; + let scrolledY; + const slides = {}; + slides.scrollHeight = 256; + slides.scrollTo = (x, y) => { + scrolledX = x; + scrolledY = y; + }; + + scripts.scrollToAdjacent(spans, undefined, undefined, true, undefined); + + expect(scrolledX).to.be.undefined; + expect(scrolledY).to.be.undefined; + }); +});