<section id="my-namespace" class="cwf-carousel" aria-roledescription="carousel" aria-label="Example carousel with navigation" data-navigation="true">
<div id="my-namespace__wrapper" class="cwf-carousel__wrapper" aria-atomic="false" aria-live="polite">
<img id="example-carousel__image--1" alt="Random 16:9 image from picsum.photos" src="https://picsum.photos/seed/first/1920/1080" />
<img id="example-carousel__image--2" alt="Random 16:9 image from picsum.photos" src="https://picsum.photos/seed/second/1920/1080" />
<img id="example-carousel__image--3" alt="Random 16:9 image from picsum.photos" src="https://picsum.photos/seed/third/1920/1080" />
<img id="example-carousel__image--4" alt="Random 16:9 image from picsum.photos" src="https://picsum.photos/seed/fourth/1920/1080" />
</div>
<nav class="cwf-carousel__controls">
<div class="cwf-carousel__container">
<div class="cwf-carousel__pagination"></div>
<div class="cwf-carousel__navigation">
<button class="cwf-carousel__button cwf-carousel__button--previous" aria-controls="my-namespace__wrapper" aria-label="Previous slide" title="Previous slide">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" class="cwf-carousel__chevron cwf-carousel__chevron--left" role="presentation">
<!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path fill="currentColor" d="M224 480c-8.188 0-16.38-3.125-22.62-9.375l-192-192c-12.5-12.5-12.5-32.75 0-45.25l192-192c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25L77.25 256l169.4 169.4c12.5 12.5 12.5 32.75 0 45.25C240.4 476.9 232.2 480 224 480z" />
</svg>Previous
</button>
<button class="cwf-carousel__button cwf-carousel__button--next" aria-controls="my-namespace__wrapper" aria-label="Next slide" title="Next slide">
Next<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" class="cwf-carousel__chevron cwf-carousel__chevron--right" role="presentation">
<!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path fill="currentColor" d="M96 480c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L242.8 256L73.38 86.63c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l192 192c12.5 12.5 12.5 32.75 0 45.25l-192 192C112.4 476.9 104.2 480 96 480z" />
</svg> </button>
</div>
</div>
</nav>
</section>
{%- set namespace = 'cwf-carousel' -%}
{%- set id_value = id ?? namespace -%}
{%- set wrapper_id = id_value ~ '__wrapper' -%}
{%- set id_attribute = 'id="' ~ id_value ~ '"' -%}
{%- set classes = [namespace] -%}
{%- set class_attribute = 'class="' ~ (classes|join(' ')) ~ '"' -%}
{%- set aria_roledescription = 'aria-roledescription="carousel"' -%}
{%- set attributes = [id_attribute, class_attribute, aria_roledescription] -%}
{%- if title -%}
{% set attributes = attributes|merge(['aria-label="' ~ title ~ '"']) %}
{%- endif -%}
{%- set controls = navigation or (autoplay and (slides|length) > 1) -%}
{%- if controls -%}
{% set attributes = attributes|merge(['data-navigation="true"']) %}
{%- endif -%}
{%- if autoplay -%}
{% set attributes = attributes|merge(['data-autoplay="true"']) %}
{%- endif -%}
<section {{ attributes|join(' ') }}>
<div id="{{ wrapper_id }}"
class="{{ namespace }}__wrapper"
aria-atomic="false"
aria-live="{{ autoplay ? 'off' : 'polite' }}">
{% for slide in slides %}
{{ slide }}
{% endfor %}
</div>
<nav class="{{ namespace }}__controls">
<div class="{{ namespace }}__container">
{%- if controls -%}
{%- if autoplay -%}
<button class="{{ namespace }}__button {{
namespace
}}__button--autoplay {{ namespace }}__button--play"
data-action="pause"
aria-label="Pause the carousel"
title="Pause the carousel">
{% include '../../shared/icons/play-solid.svg' with {
class: namespace ~ '__play',
role: 'presentation'
} %}
{% include '../../shared/icons/pause-solid.svg' with {
class: namespace ~ '__pause',
role: 'presentation'
} %}
<span class="{{ namespace }}__text">Pause</span>
</button>
{%- endif -%}
<div class="{{ namespace }}__pagination"></div>
<div class="{{ namespace }}__navigation">
<button class="{{ namespace }}__button {{
namespace
}}__button--previous"
aria-controls="{{ wrapper_id }}"
aria-label="Previous slide"
title="Previous slide">
{% include '../../shared/icons/chevron-left-solid.svg' with {
class: namespace ~ '__chevron ' ~ namespace
~ '__chevron--left',
role: 'presentation'
} %}Previous
</button>
<button class="{{ namespace }}__button {{
namespace
}}__button--next"
aria-controls="{{ wrapper_id }}"
aria-label="Next slide"
title="Next slide">
Next{% include '../../shared/icons/chevron-right-solid.svg' with {
class: namespace ~ '__chevron ' ~ namespace
~ '__chevron--right',
role: 'presentation'
} %}
</button>
</div>
{%- else -%}
<div class="{{ namespace }}__scrollbar"></div>
{%- endif -%}
</div>
</nav>
</section>
{
"id": "my-namespace",
"slides": [
"<img id=\"example-carousel__image--1\" alt=\"Random 16:9 image from picsum.photos\" src=\"https://picsum.photos/seed/first/1920/1080\" />",
"<img id=\"example-carousel__image--2\" alt=\"Random 16:9 image from picsum.photos\" src=\"https://picsum.photos/seed/second/1920/1080\" />",
"<img id=\"example-carousel__image--3\" alt=\"Random 16:9 image from picsum.photos\" src=\"https://picsum.photos/seed/third/1920/1080\" />",
"<img id=\"example-carousel__image--4\" alt=\"Random 16:9 image from picsum.photos\" src=\"https://picsum.photos/seed/fourth/1920/1080\" />"
],
"navigation": true,
"title": "Example carousel with navigation"
}
// Carousel component styles
@use "../../shared/animation";
@use "../../shared/media";
@use "../../shared/style";
@use "../../shared/theme";
// Selector prefix
$prefix: "cwf" !default;
// Swiper mixins
@mixin android {
#{if(&, ".swiper-android &", ".swiper-android")} {
@content;
}
}
@mixin pointer-events {
#{if(&, ".swiper-pointer-events &", ".swiper-pointer-events")} {
@content;
}
}
@mixin fade {
#{if(&, ".swiper-fade &", ".swiper-fade")} {
@content;
}
}
// Placeholder selectors
%carousel-slide-size {
flex-shrink: 0;
width: 100%;
height: 100%;
margin-bottom: 0;
}
%carousel-translate-3d-reset {
transform: translate3d(0px, 0, 0);
}
// Carousel
.#{$prefix}-carousel {
display: flex;
flex-direction: column;
background-color: style.color("white-dark");
@include style.spacing;
overflow: hidden;
@include style.z-index("content", "middle");
@mixin within-grid {
.cwf-grid > .#{$prefix}-carousel & {
@content;
}
}
// Wrapper
&__wrapper {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: 100%;
transition-property: transform;
@include style.z-index("content", "middle");
@extend %carousel-translate-3d-reset;
@include style.cursor(grab);
& > * {
@extend %carousel-slide-size;
margin-bottom: 0;
}
}
// Slide
&__slide {
@extend %carousel-slide-size;
transition-property: transform;
@include android {
@extend %carousel-translate-3d-reset;
}
@include fade {
pointer-events: none;
transition-property: opacity;
}
&--active {
@include fade {
pointer-events: auto;
}
}
}
// Controls
$controls__padding: 0.5rem;
$controls__padding--md: 1rem;
&__controls {
--#{$prefix}-carousel__controls--padding: #{$controls__padding};
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding-top: var(--#{$prefix}-carousel__controls--padding);
background-color: style.color("white");
@include media.breakpoint {
.#{$prefix}-carousel:is([data-navigation], [data-autoplay]) & {
--#{$prefix}-carousel__controls--padding: #{$controls__padding--md};
}
}
}
@mixin controls {
.#{$prefix}-carousel__controls & {
@content;
}
}
// Container
&__container {
@include theme.contain;
@include controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
}
// Scrollbar
$scrollbar__background-color: style.darken("white-dark", 5%) !default;
$scrollbar__background-color--md: style.color("white") !default;
&__scrollbar {
flex: 1;
position: relative;
height: 1rem;
background-color: $scrollbar__background-color;
@include style.cursor(pointer);
@include media.breakpoint {
height: 0.5rem;
}
}
$handle__background-color: theme.accent--background() !default;
&__handle {
position: relative;
left: 0;
top: 0;
width: 100%;
height: 1rem;
background-color: $handle__background-color;
@include style.cursor(grab);
@include media.breakpoint {
height: 0.5rem;
}
}
// Pagination
$pagination__padding: 0.75rem !default;
$pagination__padding--md: 1rem !default;
&__pagination {
--#{$prefix}-carousel__pagination--padding: #{$pagination__padding};
padding-left: var(--#{$prefix}-carousel__pagination--padding);
padding-right: var(--#{$prefix}-carousel__pagination--padding);
@include media.breakpoint {
--#{$prefix}-carousel__pagination--padding: #{$pagination__padding--md};
}
}
// Fraction
&__fraction {
@include media.breakpoint {
display: none;
@include within-grid {
display: block;
}
}
}
// Dots
$dots__display: none !default;
$dots__display--md: flex !default;
$dots__display--md--in-grid: none !default;
&__dots {
--#{$prefix}-carousel__dots--display: #{$dots__display};
display: var(--#{$prefix}-carousel__dots--display);
@include media.breakpoint {
--#{$prefix}-carousel__dots--display: #{$dots__display--md};
align-items: center;
flex-wrap: wrap;
gap: 1rem;
@include within-grid {
--#{$prefix}-carousel__dots--display: #{$dots__display--md--in-grid};
}
}
}
// Dot
$dot__width: 1.75rem !default;
$dot__width--ends: 1.5rem !default;
$dot__height: 0.5rem !default;
$dot__background-color: style.color("white-darkest") !default;
$dot__background-color--active: theme.accent--background() !default;
$dot__scale: 1 !default;
$dot__scale--active: 1.5 !default;
$dot__angle: style.angle(
$width: $dot__width,
$height: $dot__height,
$side: "both"
) !default;
$dot__angle--first: style.angle(
$width: $dot__width--ends,
$height: $dot__height
) !default;
$dot__angle--last: style.angle(
$width: $dot__width--ends,
$height: $dot__height,
$side: "left"
) !default;
&__dot {
--#{$prefix}-carousel__dot--width: #{$dot__width};
--#{$prefix}-carousel__dot--height: #{$dot__height};
--#{$prefix}-carousel__dot--background-color: #{$dot__background-color};
--#{$prefix}-carousel__dot--scale: #{$dot__scale};
--#{$prefix}-carousel__dot--angle: #{$dot__angle};
display: block;
width: var(--#{$prefix}-carousel__dot--width);
height: var(--#{$prefix}-carousel__dot--height);
padding: 0;
border: none;
border-radius: 0;
background-color: var(--#{$prefix}-carousel__dot--background-color);
transform: scale(var(--#{$prefix}-carousel__dot--scale));
clip-path: var(--#{$prefix}-carousel__dot--angle);
@include animation.transition(background-color, transform);
&:first-child {
--#{$prefix}-carousel__dot--angle: #{$dot__angle--first};
}
&:first-child,
&:last-child {
--#{$prefix}-carousel__dot--width: #{$dot__width--ends};
}
&:last-child {
--#{$prefix}-carousel__dot--angle: #{$dot__angle--last};
}
@include style.cursor(pointer);
&--active {
--#{$prefix}-carousel__dot--background-color: #{$dot__background-color--active};
--#{$prefix}-carousel__dot--scale: #{$dot__scale--active};
}
}
// Navigation
&__navigation {
display: flex;
}
$button__font-size: 1rem !default;
$button__background-color: transparent !default;
$button__background-color--interact: style.color("white-dark") !default;
$button__background-color--disabled: transparent !default;
$button__color: style.color("gray") !default;
$button__color--interact: theme.accent--background() !default;
$button__color--disabled: style.color("gray-lightest") !default;
// Buttons
&__button {
--#{$prefix}-carousel__button--background-color: #{$button__background-color};
--#{$prefix}-carousel__button--font-size: #{$button__font-size};
--#{$prefix}-carousel__button--color: #{$button__color};
display: flex;
align-items: center;
background-color: var(--#{$prefix}-carousel__button--background-color);
padding: 0.5rem 0.75rem;
border: none;
border-radius: 0.25rem;
font-size: var(--#{$prefix}-carousel__button--font-size);
color: var(--#{$prefix}-carousel__button--color);
@include animation.transition(color);
@include style.cursor(pointer);
&:hover,
&:focus {
--#{$prefix}-carousel__button--background-color: #{$button__background-color--interact};
--#{$prefix}-carousel__button--color: #{$button__color--interact};
}
&--disabled {
--#{$prefix}-carousel__button--background-color: #{$button__background-color--disabled} !important;
--#{$prefix}-carousel__button--color: #{$button__color--disabled} !important;
@include style.cursor(not-allowed);
}
// Play/pause buttons
&--play .#{$prefix}-carousel__play,
&--pause .#{$prefix}-carousel__pause {
display: none;
}
}
// Icons
$icon__size--play-pause: var(
--#{$prefix}-carousel__button--font-size
) !default;
$icon__size--chevron: calc(
var(--#{$prefix}-carousel__button--font-size) * 1.125
) !default;
@mixin icon($size) {
min-width: $size;
width: $size;
min-height: $size;
height: $size;
}
&__play,
&__pause {
@include icon($icon__size--play-pause);
}
&__chevron {
@include icon($icon__size--chevron);
}
&__play,
&__pause,
&__chevron--left {
margin-right: 0.5rem;
}
&__chevron--right {
margin-left: 0.5rem;
}
}
// Pointer events
@include pointer-events {
touch-action: pan-y;
}
// The default component class
import { Component } from '../../shared/component.js';
// Swiper.js and all needed modules
import Swiper, {
Autoplay,
EffectFade,
Navigation,
Pagination,
Scrollbar
} from 'swiper';
// Check whether reduced motion is enabled locally or globally
import { reducedMotion } from '../../shared/media/index.js';
// Find focusable descendants and toggle an element's tab order
import {
descendants as getFocusableDescendants,
toggle as toggleTabOrder,
remove as removeTabOrder
} from '../../shared/focus.js';
// Provide functionality to all carousels
export class Carousel extends Component {
constructor({
prefix = 'cwf',
carousel = 'carousel',
wrapper = 'carousel__wrapper',
slide = 'carousel__slide',
activeSlide = 'carousel__slide--active',
nextSlide = 'carousel__slide--next',
previousSlide = 'carousel__slide--previous',
visibleSlide = 'carousel__slide--visible',
scrollbar = 'carousel__scrollbar',
handle = 'carousel__handle',
pagination = 'carousel__pagination',
fraction = 'carousel__fraction',
number = 'carousel__number',
currentNumber = 'carousel__number--current',
totalNumber = 'carousel__number--total',
dots = 'carousel__dots',
dot = 'carousel__dot',
activeDot = 'carousel__dot--active',
nextButton = 'carousel__button--next',
prevButton = 'carousel__button--previous',
disabledButton = 'carousel__button--disabled',
autoplayButton = 'carousel__button--autoplay',
playButton = 'carousel__button--play',
pauseButton = 'carousel__button--pause',
text = 'carousel__text'
} = {}) {
super({
prefix,
classes: {
carousel,
wrapper,
slide,
activeSlide,
nextSlide,
previousSlide,
visibleSlide,
scrollbar,
handle,
pagination,
fraction,
number,
currentNumber,
totalNumber,
dots,
dot,
activeDot,
nextButton,
prevButton,
disabledButton,
autoplayButton,
playButton,
pauseButton,
text
}
});
// Setup the default parameters for all configurations
this.defaultParameters = {
keyboard: {
enabled: true
},
slideActiveClass: this.classes.activeSlide,
slideClass: this.classes.slide,
slideNextClass: this.classes.nextSlide,
slidePrevClass: this.classes.previousSlide,
wrapperClass: this.classes.wrapper
};
// Bind "this" to the necessary methods
this.autoplayButtonOnClick = this.autoplayButtonOnClick.bind(this);
this.pauseAutoplayOnInteraction =
this.pauseAutoplayOnInteraction.bind(this);
}
// Returns a copy of the default parameters
get parameters() {
return Object.assign({}, this.defaultParameters);
}
// Returns whether reduced motion is enabled locally/globally
get reducedMotion() {
return reducedMotion();
}
// Setup a carousel module
module({ reference, modules, selector, key, config, callbacks, ...other }) {
// First, store a copy of the default parameters
let parameters = reference.parameters || this.parameters;
// Next, setup the modules array (it it hasn't been already)...
if (!parameters.modules) parameters.modules = [];
// ... and push the provided modules to the corresponding array
parameters.modules.push(...modules);
// Next, store the config at the given key in the parameters...
parameters[key] = config;
// If a selector has been specified,...
if (selector) {
// Next, if the selector is a string, turn it into a map for a single element
if (typeof selector === 'string') selector = { el: selector };
// Then, for each element selector,...
Object.keys(selector).forEach((element) => {
// ... provide a target element for it
parameters[key][element] = reference.carousel.querySelector(
selector[element]
);
});
}
// If callbacks have been provided, add them to the parameters
if (callbacks) reference.callbacks = callbacks;
// If other parameter settings have been provided, assign them to the parameters
if (Object.keys(other).length)
parameters = Object.assign(parameters, other);
// Finally, add the parameters to the reference (if it doesn't exist)...
if (!reference.parameters) reference.parameters = parameters;
// ... and return it
return reference;
}
// Setup a carousel's fade effect module
fadeEffect(reference) {
return this.module({
reference,
modules: [EffectFade],
key: 'fadeEffect',
config: {
crossFade: true
},
effect: 'fade',
slideVisibleClass: this.classes.visibleSlide
});
}
// Find the associated reference of a given element
findReferenceOfElement(element) {
return this.references.find(
({ autoplay = {}, carousel, slides, wrapper }) =>
[autoplay?.button, autoplay?.text, carousel, ...slides, wrapper]
.filter(Boolean)
.includes(element)
);
}
// Update the autoplay button when functionality is changed
autoplayButtonUpdate(button, running) {
// Get the associated reference of the autoplay button...
const reference = this.findReferenceOfElement(button);
// ... and if it doesn't exist, do nothing else
if (!reference) return;
// Next, grab the autoplay object...
const { autoplay } = reference;
// ... and its text from the reference
const { text } = autoplay;
// Next, define a text action...
const action = running ? 'Pause' : 'Play';
// ... and a message using it
const message = `${action} the carousel`;
// Set the button's aria-label and title to the message
button.dataset.action = action.toLowerCase();
button.setAttribute('aria-label', message);
button.title = message;
// Toggle the play/pause class based on the state
button.classList.toggle(this.classes.playButton, running);
button.classList.toggle(this.classes.pauseButton, !running);
// Set the button text to the action
text.textContent = action;
}
// Control autoplay functionality when the autoplay button is clicked
autoplayButtonOnClick({ currentTarget: autoplayButton }) {
// Get the associated reference of the autoplay button...
const reference = this.findReferenceOfElement(autoplayButton);
// ... and if it doesn't exist, do nothing else
if (!reference) return;
// Get the button's action...
const { action } = autoplayButton.dataset;
// ... and start/stop the carousel based on it
switch (action) {
case 'play':
return reference.swiper.autoplay.start();
case 'pause':
return reference.swiper.autoplay.stop();
}
}
// Setup the autoplay button functionality
autoplayButtonFunctionality(reference) {
// Grab the carousel, swiper, and autoplay from the reference...
const { carousel, swiper, autoplay = {} } = reference;
// ... and if the autoplay object doesn't already exist, add it to the reference
if (!Object.keys(autoplay).length) reference.autoplay = {};
// Store the button from the reference or grab it...
const button =
autoplay?.button ||
carousel.querySelector(this.selectors.autoplayButton);
// ... and if doesn't exist, do nothing else
if (!button) return;
// Store the button text from the reference or grab it...
const text =
autoplay?.text || button.querySelector(this.selectors.text);
// ... and if it doesn't exist, do nothing else
if (!text) return;
// Othwerwise, handle the button's click event to control the carousel functionality
button[this.listener]('click', this.autoplayButtonOnClick);
// If the autoplay button hasn't been saved to the reference, save it
if (!autoplay.button) reference.autoplay.button = button;
// If the autoplay text hasn't been saved to the reference, save it
if (!autoplay.text) reference.autoplay.text = text;
// If the component is stopping, reset the autoplay button and do nothing else
if (!this.run) return this.autoplayButtonUpdate(button, true);
// Otherwise, handle autoplay button updates when the carousel starts...
swiper.on('autoplayStart', () =>
this.autoplayButtonUpdate.bind(this)(button, true)
);
// ... and stops autoplay functionality
swiper.on('autoplayStop', () =>
this.autoplayButtonUpdate.bind(this)(button, false)
);
}
// Pause autoplay when the wrapper is hovered
pauseAutoplayOnInteraction({ currentTarget }) {
// Grab this current target's associated reference...
const reference = this.findReferenceOfElement(currentTarget);
// ... and if doesn't exist, do nothing else
if (!reference) return;
// Otherwise, stop autoplay
return reference.swiper.autoplay.stop();
}
// Handle the autoplay wrapper functionality
autoplayWrapperFunctionality({ wrapper, swiper }) {
// If no wrapper is found, do nothing
if (!wrapper) return;
// When the wrapper is hovered, pause autoplay
wrapper[this.listener]('mouseenter', this.pauseAutoplayOnInteraction);
// If unmounting, reset the wrapper's aria-live attribute
if (!this.run) return wrapper.setAttribute('aria-live', 'off');
// Update the wrapper's aria-live attribute when autoplay is started...
swiper.on('autoplayStart', () =>
wrapper.setAttribute('aria-live', 'off')
);
// ... and stopped
swiper.on('autoplayStop', () =>
wrapper.setAttribute('aria-live', 'polite')
);
}
// Handle the autoplay slide functionality
autoplaySlideFunctionality({ slides }) {
// If no slides are found, do nothing
if (!slides || !slides.length) return;
// For each slide,...
slides.forEach((slide) =>
// ... pause autoplay when focused
slide[this.listener]('focus', this.pauseAutoplayOnInteraction)
);
}
// Setup a carousel's autplay module
autoplay(reference) {
return this.module({
reference,
modules: [Autoplay],
key: 'autoplay',
config: {
delay: 5000
},
callbacks: [
this.autoplayButtonFunctionality,
this.autoplayWrapperFunctionality,
this.autoplaySlideFunctionality
]
});
}
// Setup a carousel's navigation module
navigation(reference) {
return this.module({
reference,
modules: [Navigation],
selector: {
nextEl: this.selectors.nextButton,
prevEl: this.selectors.prevButton
},
key: 'navigation',
config: {
disabledClass: this.classes.disabledButton
}
});
}
// Render a pagination number
renderNumber(className, value) {
// Define an array of classes...
const classes = [this.classes.number, className];
// ... and return a span using them and the given value
return `<span class="${classes.join(' ')}">${value}</span>`;
}
// Render the pagination fraction
renderFraction(current, total) {
// Define the current...
const currentNumber = this.renderNumber(
this.classes.currentNumber,
current
);
// ... and total number
const totalNumber = this.renderNumber(this.classes.totalNumber, total);
// Finally, define a label...
const label = `On slide ${current} of ${total}`;
// ... and use it to return the fraction with the current/total numbers wrapped
return `<div aria-label="${label}" class="${this.classes.fraction}">${currentNumber}/${totalNumber}</div>`;
}
// Render a pagination dot
renderDot(active, message, controls) {
// Define an array of classes...
const classes = [
this.classes.dot,
active ? this.classes.activeDot : false
].filter(Boolean);
// ... and return a button using them, the message, and the controls
return `<button class="${classes.join(
' '
)}" aria-label="${message}" title="${message}" aria-controls="${controls}"></button>`;
}
// Render the pagination dots
renderDots(reference, current, total) {
// Define an empty array of dots
let dots = [];
// For each dot,...
for (let number = 1; number <= total; number++) {
// ... calculate if it's active,...
const active = number === current;
// ... it's message,...
const message = `Go to slide ${number} of ${total}`;
// ... it's control ID,...
const controls = reference.wrapper.id;
// ... and use them to define the dot...
const dot = this.renderDot(active, message, controls);
// ... and push it to the dots array
dots.push(dot);
}
// Define a label...
const label = `On slide ${current} of ${total}`;
// ... and use it to return the dots wrapped
return `<div class="${
this.classes.dots
}" aria-label="${label}">${dots.join('')}</div>`;
}
// Render the custom pagination
renderCustomPagination(reference, swiper, current, total) {
// Define the fraction...
const fraction = this.renderFraction(current, total);
// ... and dots
const dots = this.renderDots(reference, current, total);
// Finally, return the fraction and dots together
return fraction + dots;
}
// Setup a carousel's pagination module
pagination(reference) {
return this.module({
reference,
modules: [Pagination],
selector: this.selectors.pagination,
key: 'pagination',
config: {
bulletActiveClass: this.classes.activeDot,
bulletClass: this.classes.dot,
clickable: true,
renderCustom: this.renderCustomPagination.bind(this, reference),
type: 'custom'
}
});
}
// Setup a carousel's scrollbar module
scrollbar(reference) {
return this.module({
reference,
modules: [Scrollbar],
selector: this.selectors.scrollbar,
key: 'scrollbar',
config: {
dragClass: this.classes.handle,
draggable: true
}
});
}
// Return an attribute string based off the run flag
get attribute() {
return this.run ? 'setAttribute' : 'removeAttribute';
}
// Update a slide's classes and attributes
slideUpdate(slide, index, slides) {
slide.classList.toggle(this.classes.slide, this.run);
slide[this.attribute]('role', 'group');
slide[this.attribute]('aria-roledescription', 'slide');
slide[this.attribute]('aria-label', `${index + 1} of ${slides.length}`);
}
// Get and setup a carousel's slides
slides({ carousel, slides: existingSlides }) {
// Grab the slides...
const slides =
existingSlides ||
Array.from(
carousel.querySelectorAll(`${this.selectors.wrapper} > *`)
);
// ... and for each, update its classes and atrributes
slides.forEach(this.slideUpdate.bind(this));
// Finally, return the slides
return slides;
}
// Store references to all carousels
store() {
// Attempt to grab all carousels...
const carousels = Array.from(
document.querySelectorAll(this.selectors.carousel)
);
// ... and if none exist, do nothing else
if (!carousels.length) return;
// Otherwise, store the references...
this.references = carousels.map((carousel) => {
// Grab the wrapper...
const wrapper = carousel.querySelector(this.selectors.wrapper);
// ... and each slide
const slides = this.slides({ carousel });
// Store a reference to the carousel as an object
let reference = { carousel, wrapper, slides };
// If reduced motion is enabled locally/globally, set up the reference's fade effect
if (this.reducedMotion) reference = this.fadeEffect(reference);
// Pull controls from the carousel's dataset
const { navigation, autoplay } = carousel.dataset;
// If no controls are specified, return the reference with the scrollbar module
if (!navigation && !autoplay) return this.scrollbar(reference);
// Otherwise, set up the reference's pagination, navigation,....
if (navigation) {
reference = this.pagination(reference);
reference = this.navigation(reference);
}
// ... and autoplay modules if defined...
if (autoplay) reference = this.autoplay(reference);
// ... and return it
return reference;
});
}
// Update a carousel's slides' and their descendants tab index
updateSlidesTabIndex({ slides }, { activeIndex = 0 }) {
// For each slide,...
return slides.forEach((slide, index) => {
// ... set the targets as it and its descendants...
const targets = [slide, ...getFocusableDescendants(slide)];
// ... and either toggle their tab order or remove it if mounting or not
return this.run
? toggleTabOrder(activeIndex === index, ...targets)
: removeTabOrder(...targets);
});
}
// Handle the functionality for each carousel
functionality(run) {
// For each reference,...
this.references.forEach((reference) => {
// Store swiper as an existing or new instance...
const swiper =
reference.swiper ||
new Swiper(reference.carousel, reference.parameters);
// ... and if it hasn't been saved to the reference, save it
if (!reference.swiper) reference.swiper = swiper;
// Update this reference's slides' tab index
this.updateSlidesTabIndex(reference, swiper);
// If unmounting, destroy the swiper instance
if (!run) swiper.destroy(true, true);
// When the swiper active index changes, update this reference's slides' tab index
swiper.on(
'activeIndexChange',
this.updateSlidesTabIndex.bind(this, reference)
);
// If there are no callbacks, do nothing else...
if (!reference.callbacks || !reference.callbacks.length) return;
// ... otherwise, run the callbacks
return reference.callbacks.forEach((callback) =>
callback.bind(this)(reference)
);
});
}
// Mount/unmount carousel functionality
mount(run) {
super.mount(run);
// If mounting, store the references
if (run) this.store();
// Handle the functionality of each carousel
this.functionality(run);
// If unmounting,...
if (!run) {
// Reset all slides...
this.references.forEach(this.slides.bind(this));
// ... and then the references
this.references = [];
}
}
}
The carousel component encapsulates multiple other components to create a horizontally scrolling area to cycle through content. This is especially useful to conserve vertical space and showcase items within a contextual group.
By default, a carousel will be styled as a simple horizontally scrolling area with a stylized scrollbar below it.
By adding a data-navigation="true"
attribute to a carousel, it enables more finite controls over the carousel. This removes the scrollbar and in its place adds pagination and previous/next navigation buttons.
By adding a data-autoplay="true"
attribute to a carousel in conjunction with a data-navigation="true"
attribute, it enables autoplay functionality. This adds a play/pause button to the left of the pagination.
Under the hood, the carousel component uses Swiper.js to power its functionality. This includes touch gesture support, generating the pagination buttons, and more.
When autoplay is enabled, there are multiple ways to pause it: