Aller au contenu

Carousel

composant

Slides in a row, on native scroll snapping: they scroll by touch, trackpad and keyboard without JavaScript; the controller adds the buttons, the arrows and, on request, the automatic play. ::scroll-button() being in one engine alone, the buttons do not rely on it.

Aperçu

Clair

One
Two
Code
<twig:Carousel label="Photos">
    <twig:Carousel:Content>
        <twig:Carousel:Item>One</twig:Carousel:Item>
        <twig:Carousel:Item>Two</twig:Carousel:Item>
    </twig:Carousel:Content>
    <twig:Carousel:Previous />
    <twig:Carousel:Next />
</twig:Carousel>

Sombre

One
Two
Code
<twig:Carousel label="Photos">
    <twig:Carousel:Content>
        <twig:Carousel:Item>One</twig:Carousel:Item>
        <twig:Carousel:Item>Two</twig:Carousel:Item>
    </twig:Carousel:Content>
    <twig:Carousel:Previous />
    <twig:Carousel:Next />
</twig:Carousel>

Essayer

Changez les propriétés ; le serveur rend l’exemple de nouveau.

One
Two
<twig:Carousel label="Photos">
    <twig:Carousel:Content>
        <twig:Carousel:Item>One</twig:Carousel:Item>
        <twig:Carousel:Item>Two</twig:Carousel:Item>
    </twig:Carousel:Content>
    <twig:Carousel:Previous />
    <twig:Carousel:Next />
</twig:Carousel>

Variantes

orientation

The axis the slides scroll along.

One
Two
One
Two
orientation="horizontal"
One
Two
One
Two
orientation="vertical"

align

The alignment of the selected slide inside the viewport.

One
Two
One
Two
align="start"
One
Two
One
Two
align="center"
One
Two
One
Two
align="end"

loop

Whether the carousel wraps around after the last slide.

One
Two
One
Two
loop="false"
One
Two
One
Two
loop="true"

Installation

php bin/console ux:install carousel --kit=github.com/parler-haut-interagir-librement/ux-kit

Elle apporte les recettes dont elle a besoin : button

Propriétés

PropriétéTypePar défautDescription
orientation 'horizontal'|'vertical' 'horizontal' The axis the slides scroll along.
align 'start'|'center'|'end' 'center' The alignment of the selected slide inside the viewport.
loop bool false Whether the carousel wraps around after the last slide.
options array {} Extra options read by the controller, merged over align and loop.
autoplay int 0 The delay in milliseconds between two automatic slide changes. Zero disables autoplay.
label string 'Carousel' The accessible name of the carousel region.
roleDescription string 'carousel' What the carousel is announced as.
pauseLabel string 'Pause' The text of the button while the slides play.
playLabel string 'Play' The text of the button once paused.

Accessibility

A named region, its slides announced as such, the texts as props. Automatic play has a pause button, holds while the carousel is hovered or focused, and never runs under prefers-reduced-motion (WCAG 2.2.2).

Fichiers à copier

assets/controllers/carousel_controller.js
import { Controller } from '@hotwired/stimulus';

/** The direction a key moves in, along the axis of the carousel and the direction of the page; 0 for any other key. */
export function direction(key, { vertical = false, rtl = false } = {}) {
    if (vertical) {
        return { ArrowDown: 1, ArrowUp: -1 }[key] ?? 0;
    }

    return { ArrowRight: rtl ? -1 : 1, ArrowLeft: rtl ? 1 : -1 }[key] ?? 0;
}

/** Where a scroll stands: at the start, at the end, and on which of `count` slides of `size` pixels. */
export function standing(position, max, size, count) {
    const distance = Math.abs(position);

    return {
        atStart: distance <= 1,
        atEnd: distance >= max - 1,
        index: size > 0 ? Math.min(count - 1, Math.max(0, Math.round(distance / size))) : 0,
    };
}

/*
 * The buttons, the arrows and the automatic play of a carousel whose slides are scrolled and snapped by the browser.
 * Without JavaScript the buttons stay hidden and the slides still scroll. Automatic play holds while the carousel is
 * hovered or holds the focus and while the page is hidden, and never runs under `prefers-reduced-motion`.
 */
export default class extends Controller {
    static targets = ['viewport', 'previous', 'next', 'toggle'];

    static values = {
        orientation: { type: String, default: 'horizontal' },
        options: { type: Object, default: {} },
        autoplay: { type: Number, default: 0 },
        pauseLabel: { type: String, default: '' },
        playLabel: { type: String, default: '' },
    };

    #timer = null;
    #holds = new Set();
    #paused = false;
    #index = null;

    connect() {
        for (const button of [...this.previousTargets, ...this.nextTargets]) {
            button.hidden = false;
        }

        this.update();

        if (this.#canPlay) {
            for (const toggle of this.toggleTargets) {
                toggle.hidden = false;
            }

            this.#play();
        }
    }

    disconnect() {
        this.#stop();
    }

    scrollPrev() {
        this.#step(-1, this.#loop);
    }

    scrollNext() {
        this.#step(1, this.#loop);
    }

    handleKeydown(event) {
        const moved = direction(event.key, { vertical: this.#vertical, rtl: this.#rtl });

        if (moved !== 0) {
            event.preventDefault();
            this.#step(moved, this.#loop);
        }
    }

    update() {
        const { atStart, atEnd, index } = this.#standing;

        for (const button of this.previousTargets) {
            button.disabled = atStart && !this.#loop;
        }

        for (const button of this.nextTargets) {
            button.disabled = atEnd && !this.#loop;
        }

        if (index !== this.#index) {
            this.#index = index;
            this.dispatch('select', { detail: { index, count: this.#slides.length } });
        }
    }

    hold(event) {
        this.#holds.add(event.type === 'mouseenter' ? 'pointer' : 'focus');
        this.#stop();
    }

    release(event) {
        if (event.type === 'focusout' && this.element.contains(event.relatedTarget)) {
            return;
        }

        this.#holds.delete(event.type === 'mouseleave' ? 'pointer' : 'focus');
        this.#play();
    }

    toggleAutoplay() {
        this.#paused = !this.#paused;

        for (const toggle of this.toggleTargets) {
            toggle.textContent = this.#paused ? this.playLabelValue : this.pauseLabelValue;
        }

        this.#paused ? this.#stop() : this.#play();
    }

    get #vertical() {
        return this.orientationValue === 'vertical';
    }

    get #rtl() {
        return getComputedStyle(this.element).direction === 'rtl';
    }

    get #loop() {
        return Boolean(this.optionsValue.loop);
    }

    get #reduced() {
        return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    }

    get #canPlay() {
        return this.autoplayValue > 0 && !this.#reduced;
    }

    get #slides() {
        return [...(this.viewportTarget.firstElementChild?.children ?? [])];
    }

    get #slideSize() {
        const [first, second] = this.#slides;
        const distance =
            first && second
                ? Math.abs(this.#vertical ? second.offsetTop - first.offsetTop : second.offsetLeft - first.offsetLeft)
                : 0;

        return distance > 0
            ? distance
            : this.#vertical
              ? this.viewportTarget.clientHeight
              : this.viewportTarget.clientWidth;
    }

    get #standing() {
        const viewport = this.viewportTarget;

        return this.#vertical
            ? standing(
                  viewport.scrollTop,
                  viewport.scrollHeight - viewport.clientHeight,
                  this.#slideSize,
                  this.#slides.length,
              )
            : standing(
                  viewport.scrollLeft,
                  viewport.scrollWidth - viewport.clientWidth,
                  this.#slideSize,
                  this.#slides.length,
              );
    }

    #step(moved, wrap) {
        const viewport = this.viewportTarget;
        const { atStart, atEnd } = this.#standing;
        const behavior = this.#reduced ? 'auto' : 'smooth';
        const sign = !this.#vertical && this.#rtl ? -1 : 1;

        if (wrap && moved > 0 && atEnd) {
            viewport.scrollTo({ top: 0, left: 0, behavior });
        } else if (wrap && moved < 0 && atStart) {
            viewport.scrollTo(
                this.#vertical
                    ? { top: viewport.scrollHeight, behavior }
                    : { left: sign * viewport.scrollWidth, behavior },
            );
        } else {
            const distance = moved * this.#slideSize;
            viewport.scrollBy(this.#vertical ? { top: distance, behavior } : { left: sign * distance, behavior });
        }
    }

    #play() {
        this.#stop();

        if (this.#canPlay && !this.#paused && this.#holds.size === 0) {
            this.#timer = setInterval(() => {
                if (!document.hidden) {
                    this.#step(1, true);
                }
            }, this.autoplayValue);
        }
    }

    #stop() {
        clearInterval(this.#timer);
        this.#timer = null;
    }
}
templates/components/Carousel.html.twig
{%- props
    ## 'horizontal'|'vertical' The axis the slides scroll along.
    orientation = 'horizontal',
    ## 'start'|'center'|'end' The alignment of the selected slide inside the viewport.
    align = 'center',
    ## bool Whether the carousel wraps around after the last slide.
    loop = false,
    ## array Extra options read by the controller, merged over `align` and `loop`.
    options = {},
    ## int The delay in milliseconds between two automatic slide changes. Zero disables autoplay.
    autoplay = 0,
    ## string The accessible name of the carousel region.
    label = 'Carousel',
    ## string What the carousel is announced as.
    roleDescription = 'carousel',
    ## string The text of the button while the slides play.
    pauseLabel = 'Pause',
    ## string The text of the button once paused.
    playLabel = 'Play'
-%}
{%- set _carousel_options = {align: align, loop: loop}|merge(options) -%}
{%- set _carousel_class = 'isCarouselFrame' ~ (orientation is same as('vertical') ? ' isVertical' : '') ~ ({center: ' hasCenteredSlides', end: ' hasEndSlides'}[align] ?? '') -%}
{%- do provide('carousel.orientation', orientation) -%}
{%- do provide('carousel.loop', _carousel_options.loop) -%}
<div role="region" aria-roledescription="{{ roleDescription }}" tabindex="0" data-slot="carousel" data-orientation="{{ orientation }}" data-align="{{ align }}" data-carousel-orientation-value="{{ orientation }}" data-carousel-options-value="{{ _carousel_options|json_encode }}" data-carousel-autoplay-value="{{ autoplay }}" data-carousel-pause-label-value="{{ pauseLabel }}" data-carousel-play-label-value="{{ playLabel }}"{{ attributes.defaults({class: _carousel_class, 'aria-label': label, 'data-controller': 'carousel', 'data-action': 'keydown->carousel#handleKeydown mouseenter->carousel#hold mouseleave->carousel#release focusin->carousel#hold focusout->carousel#release'}) }}>
{##- The carousel structure, typically includes `Carousel:Content`, `Carousel:Previous` and `Carousel:Next`. -#}
{%- block content %}{% endblock -%}
{%- if autoplay > 0 %}
<twig:Button class="isCarouselToggle" variant="ghost" size="sm" hidden data-slot="carousel-toggle" data-carousel-target="toggle" data-action="click->carousel#toggleAutoplay">{{ pauseLabel }}</twig:Button>
{%- endif %}
</div>
templates/components/Carousel/Content.html.twig
<div class="isCarouselViewport" data-slot="carousel-content" data-carousel-target="viewport" data-action="scroll->carousel#update:passive"><div{{ attributes.defaults({class: 'isCarouselTrack'}) }}>
{##- The carousel slides, typically multiple `Carousel:Item` components. -#}
{%- block content %}{% endblock -%}
</div></div>
templates/components/Carousel/Item.html.twig
{%- props
    ## string What the slide is announced as.
    roleDescription = 'slide'
-%}
<div role="group" aria-roledescription="{{ roleDescription }}" data-slot="carousel-item"{{ attributes.defaults({class: 'isCarouselSlide'}) }}>
{##- The slide content. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Carousel/Next.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant.
    variant = 'outline',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'icon-sm',
    ## string The accessible label of the button.
    text = 'Next slide'
-%}
<twig:Button variant="{{ variant }}" size="{{ size }}" hidden data-slot="carousel-next" data-carousel-target="next" {{ ...attributes.defaults({class: 'isCarouselNext', 'data-action': 'click->carousel#scrollNext'}) }}>
    <span aria-hidden="true">&rsaquo;</span><span class="isVisuallyHidden">{{ text }}</span>
</twig:Button>
templates/components/Carousel/Previous.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant.
    variant = 'outline',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'icon-sm',
    ## string The accessible label of the button.
    text = 'Previous slide'
-%}
<twig:Button variant="{{ variant }}" size="{{ size }}" hidden data-slot="carousel-previous" data-carousel-target="previous" {{ ...attributes.defaults({class: 'isCarouselPrevious', 'data-action': 'click->carousel#scrollPrev'}) }}>
    <span aria-hidden="true">&lsaquo;</span><span class="isVisuallyHidden">{{ text }}</span>
</twig:Button>