Last active
May 1, 2025 14:02
-
-
Save endymion1818/8119f7af21db1f62d9119581fc3a8d19 to your computer and use it in GitHub Desktop.
Carousel
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function getHiddenElementHeight(element) { | |
// Save the original display style | |
const originalDisplay = element.style.display; | |
// Temporarily show the element | |
element.style.display = 'block'; | |
// Get the height | |
const height = element.offsetHeight; | |
// Revert to the original display style | |
element.style.display = originalDisplay; | |
return height; | |
} | |
function fadeIn(element) { | |
let opacity = 0; | |
element.style.opacity = opacity; | |
element.style.display = 'block'; | |
const interval = setInterval(() => { | |
opacity += 0.05; | |
if (opacity >= 1) { | |
opacity = 1; | |
clearInterval(interval); | |
} | |
element.style.opacity = opacity; | |
}, 50); | |
} | |
function fadeOut(element) { | |
let opacity = 1; | |
element.style.opacity = opacity; | |
const interval = setInterval(() => { | |
opacity -= 0.05; | |
if (opacity <= 0) { | |
opacity = 0; | |
element.style.display = 'none'; | |
clearInterval(interval); | |
} | |
element.style.opacity = opacity; | |
}, 50); | |
} | |
/** | |
* observe changes in the height of carousel items | |
* @param {HTMLElement} carouselElement | |
* @returns {void} | |
*/ | |
function observeCarouselHeightChanges(carouselElement) { | |
if (!carouselElement) { | |
return; | |
} | |
let proposedCarouselHeight = 0; | |
// Create a ResizeObserver instance | |
const resizeObserver = new ResizeObserver(entries => { | |
let maxHeight = 0; | |
for (let entry of entries) { | |
const liElement = /** @type {HTMLElement} */ (entry.target); | |
const children = Array.from(liElement.children); | |
children.forEach(child => { | |
const height = child.offsetHeight; | |
if (height > maxHeight) { | |
maxHeight = height; | |
} | |
}); | |
} | |
if (maxHeight > proposedCarouselHeight) { | |
proposedCarouselHeight = maxHeight; | |
requestAnimationFrame(() => { | |
carouselElement.style.height = `${proposedCarouselHeight}px`; | |
}); | |
} | |
}); | |
// Select all <li> elements within the carousel | |
const liElements = carouselElement?.querySelectorAll('li'); | |
// Observe each <li> element | |
liElements.forEach(li => { | |
resizeObserver.observe(li); | |
}); | |
// Initial height setting | |
carouselElement.style.height = `${proposedCarouselHeight}px`; | |
} | |
/** | |
* | |
* @param {HTMLElement} carouselElement | |
* @returns a ✨ new carousel ✨ | |
* @notes | |
* | |
* The carousel has some parameters which can be modified by adding classes to the carousel element | |
* | |
* 1. Autorotation speed. Add a class of `js-autoplayspeed-<number>` where `<number>` is the speed in milliseconds to adjust the default rotation speed | |
* 3. Show indicators. Add a class of `js-show-indicators` to show indicators | |
* 4. Show navigators. Add a class of `js-show-navigators` to show navigators | |
* 5. Do not autorotate. Add a class of `js-no-autorotate` to prevent the carousel from rotating | |
* | |
*/ | |
function createCarousel( | |
carouselElement, | |
) { | |
// nope | |
if (typeof window === 'undefined') { | |
return; | |
} | |
// also nope | |
if (!carouselElement) { | |
return; | |
} | |
/** @type { HTMLCollection | null} */ | |
const carouselItems = carouselElement.children; | |
// Check if carousel has more than one item | |
if (!carouselItems || carouselItems.length <= 1) { | |
console.info("Carousel doesn't have more than one item, so it will not be activated."); | |
return; // Exit if there's only one or no items | |
} | |
let autoRotationInterval = carouselElement.className.match(/js-no-autorotate/) ? null : 3_000; | |
if (carouselElement.className.match(/js-autoplayspeed-\d+/g)) { | |
const speedClasses = carouselElement.className.match(/js-autoplayspeed-\d+/g) | |
if (!speedClasses) { | |
return; | |
} | |
const speedClass = speedClasses[0].split("-")[2] | |
autoRotationInterval = parseInt(speedClass); | |
} | |
/** @type { HTMLElement[] } */ | |
const carouselItemsArray = Array.from(carouselItems).filter(item => item instanceof HTMLElement); | |
// 1. set carousel to be full width of the containing area | |
carouselElement.style.width = '100%'; | |
// if not, use height of the tallest image | |
carouselElement.style.display = 'block'; | |
observeCarouselHeightChanges(carouselElement); | |
// finally, set the carousel to be visible | |
carouselElement.classList.add('tw-block', 'tw-relative', 'md:tw-overflow-hidden'); | |
// define interval timer so we can clear it later | |
let intervalInstance = null; | |
/** | |
* HELPER FUNCTIONS | |
*/ | |
/** | |
* Gets the currently active slide | |
* @returns {HTMLElement | Element} item | |
*/ | |
function getActiveItem() { | |
const activeItem = carouselElement?.querySelector('[data-carousel-item-current="true"]'); | |
if (!activeItem || !(activeItem instanceof HTMLElement)) { | |
// @ts-ignore carouselItems is definitely defined by this point | |
return carouselItemsArray[0]; | |
} | |
return activeItem; | |
} | |
/** | |
* | |
* gets the position of the item in the array | |
* @param {HTMLElement | Element} item | |
* @returns {number} itemIndex | |
*/ | |
function getPositionOfItem(item) { | |
const position = carouselItemsArray.findIndex((carouselItem) => { | |
return carouselItem === item && carouselItem.getAttribute('data-carousel-item'); | |
}); | |
return position; | |
} | |
/** | |
* | |
* @param {HTMLElement| Element } carouselItem | |
* @returns null | |
*/ | |
function setItemAsActive(carouselItem) { | |
carouselItem.setAttribute('data-carousel-item-current', 'true'); | |
fadeIn(carouselItem); | |
} | |
/** | |
* | |
* @param {HTMLElement| Element } carouselItem | |
* @returns null | |
*/ | |
function setItemAsInactive(carouselItem) { | |
carouselItem.setAttribute('data-carousel-item-current', 'false'); | |
fadeOut(carouselItem); | |
} | |
/** | |
* ACTIONS | |
*/ | |
/** | |
* Set an interval to cycle through the carousel items | |
* @returns {void} | |
*/ | |
function cycle() { | |
if (!autoRotationInterval || autoRotationInterval <= 0) { | |
return; | |
} | |
intervalInstance = window.setTimeout(() => { | |
next(); | |
}, autoRotationInterval); | |
} | |
function pause() { | |
clearTimeout(intervalInstance); | |
} | |
/** | |
* Clears the cycling interval | |
* @returns {void} | |
*/ | |
function pause() { | |
clearInterval(intervalInstance); | |
} | |
/** | |
* Slides to the next position | |
* | |
* @param {HTMLElement | Element} nextItem | |
* @returns {void} | |
*/ | |
function slideTo(nextItem) { | |
const activeItem = getActiveItem(); | |
if (!activeItem || !nextItem) { | |
return; | |
} | |
setItemAsInactive(activeItem); | |
setItemAsActive(nextItem); | |
showActiveIndicator(nextItem); | |
pause(); | |
cycle(); | |
} | |
function showActiveIndicator(nextItem) { | |
const nextItemIndex = getPositionOfItem(nextItem); | |
const indicators = carouselElement.querySelectorAll('[data-carousel-indicator-for]'); | |
indicators && Array.from(indicators).map((indicator, index) => { | |
if (index === nextItemIndex) { | |
indicator.setAttribute('aria-pressed', 'true'); | |
} else { | |
indicator.setAttribute('aria-pressed', 'false'); | |
} | |
}); | |
} | |
/** | |
* Based on the currently active item it will go to the next position | |
* @returns {void} | |
*/ | |
function next() { | |
const activeItem = getActiveItem(); | |
const activeItemPosition = getPositionOfItem(activeItem) ?? 0; | |
if (!carouselItemsArray) { | |
return; | |
} | |
if (activeItemPosition === carouselItemsArray.length - 1) { | |
// if it is the last item, set first item as next | |
return slideTo(carouselItemsArray[0]); | |
} | |
const nextItem = carouselItemsArray[activeItemPosition + 1]; | |
if (!nextItem.getAttribute('data-carousel-item')) { | |
// if it's an indicator, set first item as next | |
return slideTo(carouselItemsArray[0]); | |
} | |
slideTo(nextItem); | |
} | |
/** | |
* Based on the currently active item it will go to the previous position | |
* @returns {void} | |
*/ | |
function prev() { | |
if (!carouselItemsArray) return; | |
let activeItem = getActiveItem(); | |
if (!activeItem) { | |
console.info('no active item'); | |
activeItem = carouselItemsArray[0]; | |
} | |
const activeItemPosition = getPositionOfItem(activeItem); | |
const prevItem = carouselItemsArray[activeItemPosition - 1]; | |
const actualCarouselItems = carouselItemsArray.filter(item => item.getAttribute('data-carousel-item')); | |
if (!prevItem && actualCarouselItems) { | |
return slideTo(actualCarouselItems[actualCarouselItems.length - 1]); | |
} | |
slideTo(prevItem); | |
} | |
/** | |
* INIT FUNCTIONS | |
*/ | |
/** | |
* Create the indicators for the carousel | |
* @returns {void} | |
*/ | |
function createIndicators() { | |
if (!carouselElement.classList.contains('js-show-indicators')) { | |
return; | |
} | |
const indicatorContainer = ` | |
<div class="indicator-container tw-absolute tw-bottom-4 tw-left-0 tw-right-0 tw-flex tw-justify-center tw-mb-4"> | |
${carouselItemsArray.map((item, index) => ` | |
<button data-carousel-indicator-for="${index}" aria-pressed="${index === 0 ? "true" : "false"}" class="tw-w-4 tw-h-4 tw-mx-1 tw-rounded-full tw-border tw-border-primary-600 tw-transition-colors tw-duration-300 tw-ease-in-out tw-cursor-pointer hover:tw-ring-2 hover:tw-ring-primary-600 tw-bg-white aria-pressed:tw-bg-zinc-500" aria-label="Slide ${index + 1}"> | |
</button> | |
`).join('')} | |
</div> | |
`; | |
carouselElement.insertAdjacentHTML('beforeend', indicatorContainer); | |
const instantiatedIndicators = carouselElement.querySelectorAll('[data-carousel-indicator-for]'); | |
const instantiatedIndicatorsArray = Array.from(instantiatedIndicators); | |
instantiatedIndicatorsArray.map(indicator => { | |
const clickedCarouselItem = indicator.getAttribute('data-carousel-indicator-for'); | |
indicator?.addEventListener('click', () => { | |
clearTimeout(intervalInstance); | |
const carouselItem = carouselItemsArray.find((carouselItem) => carouselItem.getAttribute('data-carousel-item') === clickedCarouselItem); | |
carouselItem && slideTo(carouselItem); | |
instantiatedIndicators.forEach((indicator) => { | |
indicator.setAttribute('aria-pressed', 'false'); | |
}); | |
indicator.setAttribute('aria-pressed', 'true'); | |
}); | |
}) | |
} | |
function createNavigators() { | |
if (!carouselElement.classList.contains('js-show-navigators')) { | |
return; | |
} | |
const navigatorPrev = ` | |
<button class="carousel-navigate navigate-prev tw-absolute tw-left-0 tw-bottom-0 tw-top-0 tw-text-white tw-text-2xl tw-shadow-sm tw-transition-all hover:tw-scale-110" type="button"><span class="tw-block tw-rounded tw-transition-opacity tw-bg-white/20 tw-border-white/50 hover:tw-bg-zinc-400">←</span></button> | |
`; | |
const navigatorNext = ` | |
<button class="carousel-navigate navigate-next tw-absolute tw-right-0 tw-bottom-0 tw-top-0 tw-text-white tw-text-2xl tw-shadow-sm tw-transition-all hover:tw-scale-110" type="button"><span class="tw-block tw-rounded tw-transition-opacity tw-bg-white/20 tw-border-white/50 hover:tw-bg-zinc-400">→</span></button> | |
`; | |
carouselElement.insertAdjacentHTML('beforeend', navigatorPrev); | |
carouselElement.insertAdjacentHTML('beforeend', navigatorNext); | |
carouselElement.querySelectorAll('.carousel-navigate')?.forEach((navigator) => { | |
navigator.addEventListener('click', () => { | |
navigator.classList.contains('navigate-prev') ? prev() : next(); | |
}); | |
}); | |
} | |
/** | |
* Function to initialise carousel | |
* @returns {void} | |
*/ | |
function init() { | |
const activeItem = getActiveItem(); | |
if (!carouselItemsArray) { | |
return; | |
} | |
// Set the active item first and make it visible | |
if (activeItem && activeItem instanceof HTMLElement) { | |
activeItem.classList.add('tw-absolute', 'tw-inset-0', 'tw-block'); | |
activeItem.setAttribute('data-carousel-item-current', 'true'); | |
activeItem.style.opacity = '1'; | |
} else { | |
carouselItemsArray[0].classList.add('tw-absolute', 'tw-inset-0', 'tw-block'); | |
carouselItemsArray[0].setAttribute('data-carousel-item-current', 'true'); | |
carouselItemsArray[0].style.opacity = '1'; | |
} | |
// Apply opacity changes to other items | |
carouselItemsArray.map((item, index) => { | |
if (item !== activeItem) { | |
item.classList.add('tw-absolute', 'tw-inset-0', 'tw-hidden'); | |
item.style.opacity = '0'; | |
} | |
item.setAttribute('data-carousel-item', `${index}`); | |
}); | |
} | |
createIndicators(); | |
createNavigators(); | |
init(); | |
cycle(); | |
window.addEventListener('visibilitychange', () => { | |
if (document.visibilityState === 'visible') { | |
cycle(); | |
} else { | |
pause(); | |
} | |
}); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment