Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WEB-3134 uber Carousel & other Cols Landing component changes #569

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
199 changes: 135 additions & 64 deletions src/components/blocks/carousel/_carousel.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
* Separate control buttons can be passed in as optional param,
* otherwise will check for them nested inside the component */
const carouselInit = (carousel, ctrls = carousel.querySelector('.b-carousel__ctrls')) => {
const items = carousel.querySelectorAll('.b-carousel__item');
const items = Array.from(carousel.querySelectorAll('.b-carousel__item'));

/* Carousel logic only required for > 1 item */
if (items.length > 1) {
const carouselUnclipped = carousel.classList.contains('b-carousel--unclipped');
const viewport = carousel.querySelector('.b-carousel__viewport');
const list = carousel.querySelector('.b-carousel__list');
carousel._activeIndex = 0;
Expand All @@ -15,44 +16,66 @@ const carouselInit = (carousel, ctrls = carousel.querySelector('.b-carousel__ctr
items[carousel._activeIndex].classList.add('js-carousel__item--active');

/* ensure each item is tabbable for keyboard navigation */
Array.from(items, (item) => {
item.setAttribute('tabindex', 0);
items.forEach((item) => {
if (!item.querySelector('a, button')) {
item.setAttribute('tabindex', 0);
}
return true;
});

/* fn to set size & template alignment params */
/* fn to set size & template alignment params
* called on Window resize */
let carouselEnabled = true;
let itemsPerView = 1;

const setTemplateParams = () => {
/* set template alignment and max-widths in CSS
* based on parent element width in the document */
carousel.style.setProperty('--template-width', `${carousel.offsetWidth}px`);
/* set template alignment and widths in CSS
* based on available rendered width in the document */
const containerWidth = carousel.offsetWidth;
carousel.style.setProperty('--carousel-width', `${containerWidth}px`);

/* derive number of items shown per carousel view
* from the CSS variable set in the styles per breakpoint */
itemsPerView = parseInt(window.getComputedStyle(carousel).getPropertyValue('--items-per-view'), 10);

/* disable carousel if not needed (at current breakpoint!)
* i.e. not enough items to over-fill it */
if (items.length > itemsPerView) {
carouselEnabled = true;
if (ctrls) ctrls.classList.add('b-carousel__ctrls--active');
} else {
carouselEnabled = false;
if (ctrls) ctrls.classList.remove('b-carousel__ctrls--active');
}
};
setTemplateParams();

/* fn for setting the active item
* and scrolling into view, if required */
carousel._setActiveItem = (item) => {
list.querySelector('.js-carousel__item--active').classList.remove('js-carousel__item--active');
carousel._setActiveItem = (item, scrollToItem = true) => {
const oldActive = list.querySelector('.js-carousel__item--active');
if (oldActive) oldActive.classList.remove('js-carousel__item--active');
item.classList.add('js-carousel__item--active');

carousel._activeIndex = items.indexOf(item);

/* move active item into view */
carousel._activeIndex = Array.prototype.indexOf.call(items, item);
const itemSpan = items[1].offsetLeft - items[0].offsetLeft;
itemsOffset = carousel._activeIndex * itemSpan;
/* last items need special right-alignment */
if (carousel._activeIndex >= items.length - itemsPerView) {
itemsOffset -= ((1 - (item.offsetWidth / carousel.offsetWidth)) * carousel.offsetWidth);
itemsOffset += (items.length - carousel._activeIndex - 1) * itemSpan;
if (carouselUnclipped) {
const itemSpan = items[1].offsetLeft - items[0].offsetLeft;
itemsOffset = carousel._activeIndex * itemSpan;
/* last items need special right-alignment */
if (carousel._activeIndex >= items.length - itemsPerView) {
itemsOffset -= ((1 - (item.offsetWidth / carousel.offsetWidth)) * carousel.offsetWidth);
itemsOffset += (items.length - carousel._activeIndex - 1) * itemSpan;
}
carousel.style.setProperty('--items-offset', `-${itemsOffset}px`);
} else {
if (scrollToItem) item.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }); // eslint-disable-line no-lonely-if, max-len
}
carousel.style.setProperty('--items-offset', `-${itemsOffset}px`);

/* dispatch an event to be heard by the detachable buttons
* and anything else that needs it */
carousel.dispatchEvent(new CustomEvent('itemChange', { detail: { activeIndex: Array.prototype.indexOf.call(items, item) } }));
* and anything else waiting to react */
carousel.dispatchEvent(new CustomEvent('itemChange', { detail: { activeIndex: items.indexOf(item) } }));

/* track carousel interaction */
window.dataLayer = window.dataLayer || [];
Expand All @@ -64,8 +87,8 @@ const carouselInit = (carousel, ctrls = carousel.querySelector('.b-carousel__ctr

/* on Tabbing into an item set item active, if not already.
* requires item to be a tabbable element.
* can't use focusin listener here, as it would activate the item
* before the click listener below can do its check */
* can't use focusin listener here, as it would also fire at the start of a click event
* and activate the item before the click listener below can do its check */
viewport.addEventListener('keyup', (e) => {
if (e.key === 'Tab' && e.target.closest('.b-carousel__item:not(.js-carousel__item--active)')) {
carousel._setActiveItem(e.target.closest('.b-carousel__item'));
Expand All @@ -75,48 +98,83 @@ const carouselInit = (carousel, ctrls = carousel.querySelector('.b-carousel__ctr
/* onClick set active item if not fully in view
* else default click is allowed through */
viewport.addEventListener('click', (e) => {
const item = e.target.closest('.b-carousel__item');
const itemBox = item.getBoundingClientRect();
const viewBox = carousel.getBoundingClientRect();
if (itemBox.left < viewBox.left || itemBox.right > viewBox.right) {
e.preventDefault();
carousel._setActiveItem(item);
if (carouselEnabled) {
const item = e.target.closest('.b-carousel__item');
const itemRect = item.getBoundingClientRect();
const viewportRect = viewport.getBoundingClientRect();
if (itemRect.left < viewportRect.left || itemRect.right > viewportRect.right) {
e.preventDefault();
e.stopImmediatePropagation();
carousel._setActiveItem(item);
}
}
});

/* add swipe gesture support */
viewport.ontouchstart = (e) => {
const startXY = [e.touches[0].pageX, e.touches[0].pageY];
viewport.ontouchmove = (e2) => {
const deltaXY = [e2.touches[0].pageX - startXY[0], e2.touches[0].pageY - startXY[1]];
if (Math.abs(deltaXY[0]) > Math.abs(deltaXY[1])
&& (
(deltaXY[0] < 0 && carousel._activeIndex < items.length - 1)
|| (deltaXY[0] > 0 && carousel._activeIndex > 0)
)) {
/* if touch moves significantly horizontally
* activate prev/next item swipe */
if (Math.abs(deltaXY[0]) > 74) {
viewport.ontouchmove = null;
if (deltaXY[0] < 0) {
carousel._setActiveItem(items[carousel._activeIndex + itemsPerView] || items[items.length - 1]); // eslint-disable-line max-len
if (carouselUnclipped) {
/* add swipe gesture support */
viewport.ontouchstart = (e) => {
const startXY = [e.touches[0].pageX, e.touches[0].pageY];
viewport.ontouchmove = (e2) => {
const deltaXY = [e2.touches[0].pageX - startXY[0], e2.touches[0].pageY - startXY[1]];
if (Math.abs(deltaXY[0]) > Math.abs(deltaXY[1])
&& (
(deltaXY[0] < 0 && carousel._activeIndex < items.length - 1)
|| (deltaXY[0] > 0 && carousel._activeIndex > 0)
)) {
/* if touch moves significantly horizontally
* activate prev/next item swipe */
if (Math.abs(deltaXY[0]) > 74) {
viewport.ontouchmove = null;
if (deltaXY[0] < 0) {
carousel._setActiveItem(items[carousel._activeIndex + itemsPerView] || items[items.length - 1]); // eslint-disable-line max-len
} else {
carousel._setActiveItem(items[carousel._activeIndex - itemsPerView] || items[0]);
}
} else {
carousel._setActiveItem(items[carousel._activeIndex - itemsPerView] || items[0]);
/* else just drag */
carousel.style.setProperty('--items-offset', `${deltaXY[0] - itemsOffset}px`);
viewport.ontouchend = () => {
carousel._setActiveItem(items[carousel._activeIndex]);
viewport.ontouchend = null;
};
}
}
};
};
} else {
/* when an item intersects the carousel viewport
* set appropriate item as active
* can't do this onScrollend due to interaction issues and lack of support (safari) */
let intersectingItemIndexes = [];
const onIntersectionObserved = (entries) => {
entries.forEach((entry) => {
const intersectingItemIndex = items.indexOf(entry.target);
if (entry.isIntersecting) {
intersectingItemIndexes = [...intersectingItemIndexes, intersectingItemIndex];
} else {
/* else just drag */
carousel.style.setProperty('--items-offset', `${deltaXY[0] - itemsOffset}px`);
viewport.ontouchend = () => {
carousel._setActiveItem(items[carousel._activeIndex]);
viewport.ontouchend = null;
};
intersectingItemIndexes = intersectingItemIndexes.filter((id) => id !== intersectingItemIndex); // eslint-disable-line max-len
}
intersectingItemIndexes.sort((a, b) => a - b);
});
if (intersectingItemIndexes.length) {
carousel._setActiveItem(items[intersectingItemIndexes[0]], false);
}
};
};

/* onResize
* reset template params & re-centre active item */
/* create an observer
* to observe items becoming or ceasing to be completely within carousel viewport */
const observer = new IntersectionObserver(
onIntersectionObserved,
{ root: viewport, threshold: 0.9 },
);

/* observe each item */
items.forEach((item) => {
observer.observe(item);
});
}

/* onResize reset template params & re-centre active item */
window.addEventListener('resize', () => {
setTemplateParams();
carousel._setActiveItem(items[carousel._activeIndex]);
Expand All @@ -126,35 +184,48 @@ const carouselInit = (carousel, ctrls = carousel.querySelector('.b-carousel__ctr
* set focus on the currently active item to improve tab navigation */
viewport.addEventListener('focusin', (e) => {
if (!e.relatedTarget || !e.relatedTarget.closest('.b-carousel__viewport')) {
items[carousel._activeIndex].focus();
if (items[carousel._activeIndex].tabIndex > -1) {
items[carousel._activeIndex].focus();
} else {
items[carousel._activeIndex].querySelector('a, button').focus();
}
}
});

/* initialise carousel control buttons */
if (ctrls) {
ctrls.classList.add('b-carousel__ctrls--active');
if (carouselEnabled) {
ctrls.classList.add('b-carousel__ctrls--active');
}

const prev = ctrls.querySelector('.js-carousel__ctrl--prev');
const next = ctrls.querySelector('.js-carousel__ctrl--next');

prev.setAttribute('disabled', 'true');

/* onClick: focus prev/next item, in steps of number of items per view */
/* onClick focus prev/next item, in steps of number of items per view */
ctrls.addEventListener('click', (e) => {
if (e.target === prev) {
carousel._setActiveItem(items[carousel._activeIndex - itemsPerView] || items[0]);
} else if (e.target === next) {
e.stopImmediatePropagation();
if (e.target === next) {
carousel._setActiveItem(items[carousel._activeIndex + itemsPerView] || items[items.length - 1]); // eslint-disable-line max-len
} else if (e.target === prev) {
carousel._setActiveItem(items[carousel._activeIndex - itemsPerView] || items[0]);
}
});

/* deactivate inapropriate btn based on new state of carousel */
/* onItemChange: activate apropriate prev/next button(s)
* if there are now items before/after those currently in view */
carousel.addEventListener('itemChange', () => {
prev.removeAttribute('disabled');
next.removeAttribute('disabled');
if (carousel._activeIndex === 0) {
if (carousel._activeIndex > 0) {
prev.removeAttribute('disabled');
} else {
if (document.activeElement === prev) next.focus();
prev.setAttribute('disabled', 'true');
} else if (carousel._activeIndex >= items.length - itemsPerView) {
}
if (carousel._activeIndex < items.length - itemsPerView) {
next.removeAttribute('disabled');
} else {
if (document.activeElement === next) prev.focus();
next.setAttribute('disabled', 'true');
}
});
Expand Down
60 changes: 42 additions & 18 deletions src/components/blocks/carousel/_carousel.scss
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
@use "../../base";
@use "../../mixins";

.b-carousel {
--template-width: 1200px;
--item-max-width: var(--template-width);
--carousel-width: 100%;
--carousel-max-width: min(100vw, calc(var(--carousel-width) + 400px));
--carousel-overflow: calc((var(--carousel-max-width) - var(--carousel-width)) / 2);
--item-max-width: var(--carousel-width);
--items-per-view: 1;
--items-gap: 10px;
--items-offset: 0;
--transition-speed: 0.5s;
--transition-time: 500ms;

margin-bottom: 10px;
width: 100%;
Expand All @@ -15,13 +19,19 @@
}

@include mixins.breakpoints-bpMinSmall {
--items-gap: 20px;

margin-bottom: 20px;
}

@include mixins.breakpoints-bpMinMedium {
--items-per-view: 3;
}

&--unclipped {
--carousel-max-width: min(100vw, #{mixins.breakpoints-bp("largest")});
}

&__ctrls {
display: none;

Expand All @@ -31,41 +41,55 @@
gap: 6px;
justify-content: flex-end;
margin: 20px auto;
max-width: var(--template-width);
max-width: var(--carousel-width);
}
}
}

&__viewport {
margin: 0 calc((var(--template-width) - 100vw) / 2);
overflow: hidden;
padding: 0 calc((100vw - var(--template-width)) / 2);
margin: 0 calc(-1 * var(--carousel-overflow)) 0 0;
overflow-x: auto;
scroll-behavior: smooth; // necessary to prevent browser jumping instantly when focussing off-screen elements
scroll-snap-type: x mandatory;
scrollbar-color: base.sitecolors-siteColor("vam-grey-3") transparent;
scrollbar-width: thin;

.b-carousel--unclipped > & {
margin: 0 calc(-1 * var(--carousel-overflow));
overflow: hidden;
padding: 0 var(--carousel-overflow);
scroll-snap-type: none;
}
}

&__list {
@include mixins.unstyledelements-unstyledList;

display: flex;
gap: 10px;
gap: var(--items-gap);
transform: translateX(var(--items-offset));
transition: transform var(--transition-speed) ease;
transition: transform var(--transition-time) ease;

@include mixins.breakpoints-bpMinSmall {
gap: 30px;
// pseudo el used to pad the list so that last item right-aligns w/ ctrls
&::after {
color: transparent;
content: '.';
flex: 0 0 var(--carousel-overflow);
margin-left: calc(-1 * var(--items-gap)); // eliminate the extra flex gap this pseudo el creates
}
}

&__item {
display: flex;
flex: 0 0 calc((100% - 80px) / var(--items-per-view));
flex-direction: column;
gap: 10px;
--item-peek: calc(80px + var(--items-gap));
--item-peek-adjust: min(var(--item-peek) - min(var(--carousel-overflow), var(--item-peek)), var(--item-peek));

flex: 1 0 calc(((var(--carousel-width) - ((var(--items-per-view) - 1) * var(--items-gap)) - var(--item-peek-adjust)) / var(--items-per-view)));
max-width: var(--item-max-width);
scroll-snap-align: center;
scroll-snap-align: start;
scroll-snap-stop: normal;

@include mixins.breakpoints-bpMinSmall {
flex: 0 0 calc((100% - 120px) / var(--items-per-view));
gap: 20px;
--item-peek: calc(42px + var(--items-gap));
}

&#{&}:focus-visible {
Expand Down
Loading