blob: 90581c43af089edcd3eb3741e167e5e6b8f8490d [file] [edit]
/**
* --------------------------------------------------------------------------
* Bootstrap carousel.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import Manipulator from './dom/manipulator.js'
import SelectorEngine from './dom/selector-engine.js'
import { isRTL, isVisible } from './util/index.js'
/**
* Constants
*/
const NAME = 'carousel'
const DATA_KEY = 'bs.carousel'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const ARROW_LEFT_KEY = 'ArrowLeft'
const ARROW_RIGHT_KEY = 'ArrowRight'
const DIRECTION_LEFT = 'left'
const DIRECTION_RIGHT = 'right'
const EVENT_SLIDE = `slide${EVENT_KEY}`
const EVENT_SLID = `slid${EVENT_KEY}`
const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
const EVENT_MOUSEENTER = `mouseenter${EVENT_KEY}`
const EVENT_MOUSELEAVE = `mouseleave${EVENT_KEY}`
const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_CAROUSEL = 'carousel'
const CLASS_NAME_ACTIVE = 'active'
const CLASS_NAME_FADE = 'carousel-fade'
const CLASS_NAME_CENTER = 'carousel-center'
const CLASS_NAME_AUTO = 'carousel-auto'
const CLASS_NAME_CLONE = 'carousel-item-clone'
const CLASS_NAME_PAUSED = 'paused'
// Added to the root while the autoplay timer is running, so CSS can fill the
// active indicator like a progress bar over the current slide's interval.
const CLASS_NAME_PLAYING = 'carousel-playing'
// Shipped (`--bs-`-prefixed) custom property the indicator fill animation reads
// for its duration. The build prefixes every custom property, so the bare
// `--carousel-interval` used in the SCSS source becomes this at runtime.
const PROPERTY_INTERVAL = '--bs-carousel-interval'
// Duration (ms) of the JS-driven slide animation used for programmatic
// navigation (prev/next, indicators, wrap, and loop). We step `scrollLeft`
// ourselves over this window instead of calling `scrollBy({behavior:'smooth'})`,
// because Safari mis-scales programmatic smooth scrolls under page zoom — a
// one-slide jump sails well past the target (by the zoom factor) and the
// restored snap then visibly yanks the slide back. Animating by hand is immune
// to that and gives every jump a consistent duration.
const SCROLL_DURATION = 300
// How far below the most-visible slide a slide's IntersectionRatio can be while
// still counting as the active (left-most) slide. After a programmatic scroll
// the viewport rests a sub-pixel past the snap offset, leaving the intended
// slide a hair less visible than its fully-in neighbors; the tolerance prevents
// that rounding from skipping the active index forward.
const ACTIVE_RATIO_TOLERANCE = 0.05
const SELECTOR_ACTIVE = '.active'
// Exclude transient loop clones so index math, indicators, and active-slide
// detection only ever see the real slides.
const SELECTOR_ITEM = `.carousel-item:not(.${CLASS_NAME_CLONE})`
const SELECTOR_ACTIVE_ITEM = SELECTOR_ACTIVE + SELECTOR_ITEM
const SELECTOR_INNER = '.carousel-inner'
const SELECTOR_INDICATORS = '.carousel-indicators'
const SELECTOR_PLAY_PAUSE = '.carousel-control-play-pause'
const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]'
const SELECTOR_DATA_SLIDE_PREV = '[data-bs-slide="prev"]'
const SELECTOR_DATA_SLIDE_NEXT = '[data-bs-slide="next"]'
const SELECTOR_DATA_AUTOPLAY = '[data-bs-autoplay="true"]'
const KEY_TO_DIRECTION = {
[ARROW_LEFT_KEY]: DIRECTION_RIGHT,
[ARROW_RIGHT_KEY]: DIRECTION_LEFT
}
const ENDS_STOP = 'stop'
const ENDS_WRAP = 'wrap'
const ENDS_LOOP = 'loop'
const Default = {
autoplay: false,
ends: ENDS_LOOP,
interval: 5000,
keyboard: true,
pause: 'hover'
}
const DefaultType = {
autoplay: 'boolean',
ends: 'string',
interval: 'number',
keyboard: 'boolean',
pause: '(string|boolean)'
}
// Standard ease-in-out cubic, so the JS-driven scroll accelerates and
// decelerates like a native smooth scroll rather than moving linearly.
const easeInOutCubic = progress => (progress < 0.5 ?
4 * progress * progress * progress :
1 - ((((-2 * progress) + 2) ** 3) / 2))
/**
* Class definition
*/
class Carousel extends BaseComponent {
constructor(element, config) {
super(element, config)
// The scroll viewport. The browser owns sliding, dragging, momentum, and
// keyboard scrolling; this controller only layers on autoplay, the
// prev/next/indicator controls, and active-slide syncing.
this._viewport = SelectorEngine.findOne(SELECTOR_INNER, this._element) || this._element
this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element)
this._playPauseElement = SelectorEngine.findOne(SELECTOR_PLAY_PAUSE, this._element)
// Prev/next controls scoped to the carousel root (covers inline and stacked
// layouts). External controls placed outside `.carousel` aren't managed.
this._prevControls = SelectorEngine.find(SELECTOR_DATA_SLIDE_PREV, this._element)
this._nextControls = SelectorEngine.find(SELECTOR_DATA_SLIDE_NEXT, this._element)
this._interval = null
this._observer = null
// rAF handle for the in-flight JS-driven scroll animation (see `_animateScroll`).
this._scrollFrame = null
// True while a seamless loop transition is animating, so the
// IntersectionObserver and re-entrant navigation don't interfere.
this._looping = false
this._visibility = new Map()
// Runtime autoplay intent. Starts from the `autoplay` option, but is turned
// off once the user takes control (clicks a control, uses the keyboard,
// swipes/drags, or presses pause) so we don't move content out from under
// them (WCAG 2.2.2 Pause, Stop, Hide).
this._playing = this._config.autoplay
this._activeIndex = this._initialActiveIndex()
this._addEventListeners()
this._observeItems()
this._refreshActiveState()
if (this._playing) {
this.cycle()
}
this._updatePlayPauseControl()
}
// Getters
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
static get NAME() {
return NAME
}
// Public
next() {
this.to(this._navIndex() + 1)
}
nextWhenVisible() {
// Don't advance when the page or the carousel isn't visible
if (document.visibilityState === 'visible' && isVisible(this._element)) {
this.next()
}
}
prev() {
this.to(this._navIndex() - 1)
}
pause() {
this._clearInterval()
// Freeze the indicator progress fill; it resets to empty until cycling
// resumes and `_scheduleAutoplay` restarts it from scratch.
this._element.classList.remove(CLASS_NAME_PLAYING)
}
cycle() {
this._clearInterval()
this._scheduleAutoplay()
this._element.classList.add(CLASS_NAME_PLAYING)
}
to(index) {
// Ignore navigation while a seamless loop transition is animating
if (this._looping) {
return
}
const items = this._getItems()
const rawIndex = Number.parseInt(index, 10)
// Seamless loop: continue forward/backward into a transient clone instead of
// the visible `wrap` jump. Only the simple single-slide scroll layout
// qualifies, and reduced motion falls back to the plain wrap below.
if (this._config.ends === ENDS_LOOP && !this._prefersReducedMotion() && this._canLoop()) {
if (rawIndex > items.length - 1) {
this._loopTransition(true)
return
}
if (rawIndex < 0) {
this._loopTransition(false)
return
}
}
const targetIndex = this._normalizeIndex(rawIndex, items.length)
// Measure "current" from the live scroll position: `_activeIndex` updates
// asynchronously, so an indicator/control used mid-scroll must compare
// against where the viewport actually rests (`_navIndex` returns the tracked
// active index for fade/non-scrollable layouts).
const currentIndex = this._navIndex()
if (targetIndex === null || targetIndex === currentIndex) {
return
}
const slideEvent = EventHandler.trigger(this._element, EVENT_SLIDE, {
relatedTarget: items[targetIndex],
direction: this._direction(currentIndex, targetIndex),
from: currentIndex,
to: targetIndex
})
if (slideEvent.defaultPrevented) {
return
}
if (this._isFade()) {
this._fadeTo(targetIndex)
return
}
// Scroll mode: the IntersectionObserver fires `slid` and syncs state once
// the new slide settles into view.
this._scrollToIndex(targetIndex)
}
dispose() {
// Stop autoplay first: otherwise a pending timer would fire after the
// instance is torn down and throw on the now-null `_element`.
this._clearInterval()
if (this._observer) {
this._observer.disconnect()
}
if (this._scrollFrame !== null) {
cancelAnimationFrame(this._scrollFrame)
}
// Tidy up any in-flight loop transition: drop a stray clone and restore
// native snapping, so the viewport isn't left mid-animation.
for (const clone of SelectorEngine.find(`.${CLASS_NAME_CLONE}`, this._viewport)) {
clone.remove()
}
this._viewport.style.scrollSnapType = ''
// The pointerdown listener lives on the viewport (`.carousel-inner`), which
// `super.dispose()` doesn't clean up—it only drops listeners on `_element`.
EventHandler.off(this._viewport, EVENT_KEY)
super.dispose()
}
// Private
// Normalize an unknown `ends` value so navigation and end-control logic can't
// disagree about whether the carousel wraps.
_configAfterMerge(config) {
if (![ENDS_STOP, ENDS_WRAP, ENDS_LOOP].includes(config.ends)) {
config.ends = Default.ends
}
return config
}
_initialActiveIndex() {
const active = SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element)
const index = active ? this._getItems().indexOf(active) : 0
return Math.max(index, 0)
}
_addEventListeners() {
if (this._config.keyboard) {
EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))
}
if (this._config.pause === 'hover') {
EventHandler.on(this._element, EVENT_MOUSEENTER, () => this.pause())
EventHandler.on(this._element, EVENT_MOUSELEAVE, () => this._maybeEnableCycle())
}
// Dragging, swiping, or tapping the track is an explicit interaction
EventHandler.on(this._viewport, EVENT_POINTERDOWN, () => this._pauseFromInteraction())
}
_keydown(event) {
if (/input|textarea/i.test(event.target.tagName)) {
return
}
const direction = KEY_TO_DIRECTION[event.key]
if (direction) {
event.preventDefault()
this._pauseFromInteraction()
if (direction === DIRECTION_RIGHT) {
this.prev()
} else {
this.next()
}
}
}
_observeItems() {
// Fade mode stacks slides instead of scrolling, so there's nothing to observe
if (this._isFade() || typeof IntersectionObserver === 'undefined') {
return
}
this._observer = new IntersectionObserver(
entries => this._handleIntersection(entries),
{ root: this._viewport, threshold: [0, 0.25, 0.5, 0.75, 1] }
)
for (const item of this._getItems()) {
this._observer.observe(item)
}
}
_handleIntersection(entries) {
// A loop transition deliberately scrolls onto a transient clone; ignore the
// visibility churn so it doesn't move the active index mid-animation.
if (this._looping) {
return
}
for (const entry of entries) {
this._visibility.set(entry.target, entry.isIntersecting ? entry.intersectionRatio : 0)
}
const items = this._getItems()
const ratios = items.map(item => this._visibility.get(item) ?? 0)
const maxRatio = Math.max(...ratios)
// Pick the left-most slide that's *near* fully visible rather than the strict
// global maximum. After a programmatic scroll the viewport rests ~1px past
// the target snap offset, so the intended left-most slide reports a ratio a
// hair below the deeper, fully-visible ones (e.g. 0.997 vs 1.0). A strict max
// would skip past it and inflate the active index by one, which breaks
// multi-item next/prev. The tolerance keeps the intended slide active while
// peeking slivers (well below the max) are still ignored.
let bestIndex = this._activeIndex
if (maxRatio > 0) {
bestIndex = ratios.findIndex(ratio => ratio >= maxRatio - ACTIVE_RATIO_TOLERANCE)
}
this._setActive(bestIndex)
// Keep the end controls in sync with the scroll position even when the
// active index doesn't change (e.g. the final stretch of a multi-item
// scroll, where the left-most slide is already the last reachable one).
this._updateEndControls()
}
// The index a `next()`/`prev()` step is measured from. Scroll layouts read it
// from the live scroll position instead of `this._activeIndex`, because the
// IntersectionObserver updates that asynchronously: after one step the index
// can still be stale, so the next step would compute the same target and
// silently no-op (the "the button does nothing / can't reach the end slide"
// symptom). Fade and non-scrollable layouts have no scroll position to read,
// so they keep using the tracked active index (also what the unit tests rely
// on when there's no real layout).
_navIndex() {
if (this._isFade() || (this._viewport.scrollWidth - this._viewport.clientWidth) <= 0) {
return this._activeIndex
}
let index = this._activeIndex
let smallestDelta = Number.POSITIVE_INFINITY
for (const [itemIndex, item] of this._getItems().entries()) {
// The slide currently resting at the active position has ~zero delta.
const delta = Math.abs(this._scrollDelta(item))
if (delta < smallestDelta) {
smallestDelta = delta
index = itemIndex
}
}
return index
}
_scrollToIndex(index) {
const item = this._getItems()[index]
if (!item) {
return
}
const left = this._scrollDelta(item)
if (Math.abs(left) < 1) {
return
}
// `scroll-snap-stop: always` would clamp a programmatic scroll to a single
// snap point, breaking multi-slide jumps (an indicator click, `to()`, or
// wrapping from the last slide back to the first). Suspend snapping while we
// animate, then restore it once we arrive so the slide rests precisely on the
// snap point (honouring peek/gap).
const targetLeft = this._viewport.scrollLeft + left
this._viewport.style.scrollSnapType = 'none'
this._animateScroll(targetLeft, () => {
this._viewport.style.scrollSnapType = ''
// Without IntersectionObserver nothing else fires `slid`/updates the active
// slide after a programmatic scroll, so do it here. With the observer
// present this is a no-op (it already moved the active index to `index`).
if (!this._observer) {
this._setActive(index)
}
// The IntersectionObserver doesn't fire once the viewport has stopped, so
// refresh the end controls here to catch the final settle landing exactly
// on the scroll extent (e.g. disabling `next` at the last view).
this._updateEndControls()
})
}
// Animate `this._viewport.scrollLeft` to `targetLeft` over `SCROLL_DURATION`,
// stepping the position ourselves each frame (the caller suspends snapping
// first and restores it in `onComplete`). This replaces
// `scrollBy({behavior:'smooth'})`, whose Safari page-zoom bug made programmatic
// jumps overshoot the target and snap back. Because we set every frame's
// absolute position with an instant scroll, the animation can't overshoot and
// every jump takes the same time, in every browser.
_animateScroll(targetLeft, onComplete) {
if (this._scrollFrame !== null) {
cancelAnimationFrame(this._scrollFrame)
this._scrollFrame = null
}
const startLeft = this._viewport.scrollLeft
const distance = targetLeft - startLeft
// Reduced motion (or no rAF, e.g. unit tests): jump straight to the target.
if (this._prefersReducedMotion() || typeof requestAnimationFrame === 'undefined') {
this._viewport.scrollTo({ left: targetLeft, behavior: 'instant' })
onComplete()
return
}
let startTime = null
const step = now => {
if (startTime === null) {
startTime = now
}
const progress = Math.min((now - startTime) / SCROLL_DURATION, 1)
// `'instant'` (not the default) because the viewport sets
// `scroll-behavior: smooth` in CSS; without it each step would itself
// animate and fight this loop.
this._viewport.scrollTo({ left: startLeft + (distance * easeInOutCubic(progress)), behavior: 'instant' })
if (progress < 1) {
this._scrollFrame = requestAnimationFrame(step)
return
}
// Land exactly on target, guarding against floating-point drift.
this._viewport.scrollTo({ left: targetLeft, behavior: 'instant' })
this._scrollFrame = null
onComplete()
}
this._scrollFrame = requestAnimationFrame(step)
}
// Horizontal distance to scroll the viewport so `element` rests where the
// active slide should sit. Scroll the viewport itself rather than calling
// `element.scrollIntoView()`: the latter scrolls *every* scrollable ancestor
// (including the page), so an autoplaying carousel below the fold would yank
// the whole page to itself on each tick. Using bounding rects keeps it
// direction-agnostic (works in RTL).
_scrollDelta(element) {
const viewportRect = this._viewport.getBoundingClientRect()
const rect = element.getBoundingClientRect()
if (this._element.classList.contains(CLASS_NAME_CENTER)) {
return (rect.left + (rect.width / 2)) - (viewportRect.left + (viewportRect.width / 2))
}
// Start alignment: rest the slide at the scroll-padding (peek) offset, which
// is exactly where scroll-snap will settle. Aligning flush to the edge
// instead would make the browser re-snap by `peek` once snapping is restored,
// producing a visible secondary nudge after the programmatic scroll.
const padStart = Number.parseFloat(getComputedStyle(this._viewport).scrollPaddingInlineStart) || 0
return isRTL() ?
rect.right - (viewportRect.right - padStart) :
rect.left - (viewportRect.left + padStart)
}
// Seamless loop: continue past an end into a one-off clone of the destination
// slide, then teleport to the real slide so there's no visible backward jump.
_loopTransition(isNext) {
const items = this._getItems()
const last = items.length - 1
const fromIndex = this._activeIndex
const toIndex = isNext ? 0 : last
const direction = this._loopDirection(isNext)
const slideEvent = EventHandler.trigger(this._element, EVENT_SLIDE, {
relatedTarget: items[toIndex],
direction,
from: fromIndex,
to: toIndex
})
if (slideEvent.defaultPrevented) {
return
}
this._looping = true
const clone = (isNext ? items[0] : items[last]).cloneNode(true)
clone.classList.add(CLASS_NAME_CLONE)
clone.classList.remove(CLASS_NAME_ACTIVE)
clone.removeAttribute('id')
// Also strip ids from the cloned subtree to avoid duplicate ids while the
// clone is on screen.
for (const node of SelectorEngine.find('[id]', clone)) {
node.removeAttribute('id')
}
clone.setAttribute('aria-hidden', 'true')
clone.inert = true
this._viewport.style.scrollSnapType = 'none'
if (isNext) {
this._viewport.append(clone)
} else {
this._viewport.prepend(clone)
// Prepending shifts the real slides to the right; instantly re-align the
// current slide so the insertion doesn't flash before we animate.
this._jumpScroll(this._scrollDelta(items[fromIndex]))
}
this._animateScroll(this._viewport.scrollLeft + this._scrollDelta(clone), () => {
// Teleport to the real destination without animation. JS runs to
// completion before the browser paints, so removing the clone and the
// compensating scroll land in a single frame (no visible flash).
clone.remove()
this._jumpScroll(this._scrollDelta(items[toIndex]))
this._activeIndex = toIndex
this._refreshActiveState()
EventHandler.trigger(this._element, EVENT_SLID, {
relatedTarget: items[toIndex],
direction,
from: fromIndex,
to: toIndex
})
this._viewport.style.scrollSnapType = ''
this._looping = false
})
}
_loopDirection(isNext) {
if (isRTL()) {
return isNext ? DIRECTION_RIGHT : DIRECTION_LEFT
}
return isNext ? DIRECTION_LEFT : DIRECTION_RIGHT
}
// Instant (non-animated) scroll with snapping suspended, used to teleport the
// viewport during a loop transition. `behavior: 'instant'` is required because
// the viewport sets `scroll-behavior: smooth` in CSS, and `'auto'` would defer
// to it and animate the teleport (a visible backward slide).
_jumpScroll(delta) {
this._viewport.style.scrollSnapType = 'none'
this._viewport.scrollBy({ left: delta, top: 0, behavior: 'instant' })
}
// Fade mode just swaps the active class; the CSS opacity transition on
// `.carousel-item` performs the crossfade over `--carousel-fade-duration` (and
// collapses to an instant swap under reduced motion, via the `transition`
// mixin). It deliberately avoids the View Transition API: a view transition
// crossfades a page snapshot over its own (shorter) duration while this CSS
// fade also runs underneath, so the two animations overlap and visibly stutter.
_fadeTo(index) {
this._setActive(index)
}
_setActive(index) {
const items = this._getItems()
if (index === this._activeIndex || !items[index]) {
return
}
const from = this._activeIndex
this._activeIndex = index
this._refreshActiveState()
EventHandler.trigger(this._element, EVENT_SLID, {
relatedTarget: items[index],
direction: this._direction(from, index),
from,
to: index
})
}
_refreshActiveState() {
const items = this._getItems()
for (const [index, item] of items.entries()) {
item.classList.toggle(CLASS_NAME_ACTIVE, index === this._activeIndex)
}
this._setActiveIndicatorElement(this._activeIndex)
this._updateEndControls()
}
_updateEndControls() {
// Only `ends: 'stop'` has real ends; under `wrap`/`loop` you can always
// advance, so disabling end controls would be meaningless. When stopping,
// disable the prev control at the start of the scroll range and the next
// control at the end so there are no dead end-buttons.
if (this._config.ends !== ENDS_STOP) {
return
}
const viewport = this._viewport
const maxScroll = viewport.scrollWidth - viewport.clientWidth
let atStart
let atEnd
if (maxScroll > 0) {
// Scrollable: measure the real scroll extent so this works for multi-item,
// peek, and variable-width layouts where the last slide can never become
// the left-most (active) one. `Math.abs` keeps it correct in RTL, where
// `scrollLeft` runs from 0 down to negative.
const progress = Math.abs(viewport.scrollLeft)
atStart = progress <= 1
atEnd = progress >= maxScroll - 1
} else {
// Not scrollable (or no layout yet, e.g. in unit tests): fall back to the
// active index for the single-slide case.
const last = this._getItems().length - 1
atStart = this._activeIndex <= 0
atEnd = this._activeIndex >= last
}
this._setControlsDisabled(this._prevControls, atStart)
this._setControlsDisabled(this._nextControls, atEnd)
}
_setControlsDisabled(controls, disabled) {
for (const control of controls) {
// a11y: if we're about to disable the focused control, move focus to the
// opposite (still-enabled) control so focus isn't lost.
if (disabled && control === document.activeElement) {
const opposite = controls === this._prevControls ? this._nextControls : this._prevControls
const fallback = opposite[0] ?? this._viewport
// `preventScroll` so moving focus doesn't yank the page/viewport to the
// newly-focused control mid-navigation.
fallback.focus({ preventScroll: true })
}
control.disabled = disabled
}
}
_setActiveIndicatorElement(index) {
if (!this._indicatorsElement) {
return
}
const active = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement)
if (active) {
active.classList.remove(CLASS_NAME_ACTIVE)
active.removeAttribute('aria-current')
}
const newActive = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement)
if (newActive) {
newActive.classList.add(CLASS_NAME_ACTIVE)
newActive.setAttribute('aria-current', 'true')
}
}
_normalizeIndex(index, length) {
if (Number.isNaN(index) || length === 0) {
return null
}
if (index < 0) {
return this._wrapsAround() ? length - 1 : null
}
if (index > length - 1) {
return this._wrapsAround() ? 0 : null
}
return index
}
// Whether navigating past an end wraps to the other end. `loop` continues
// seamlessly where it can (see `_canLoop`) and otherwise behaves like `wrap`.
_wrapsAround() {
return this._config.ends === ENDS_WRAP || this._config.ends === ENDS_LOOP
}
// Seamless looping is only supported for the simple single-slide scroll
// layout. Multi-item, peek, center, and variable-width layouts fall back to
// the plain `wrap` jump.
_canLoop() {
if (this._isFade() || this._getItems().length < 2) {
return false
}
const styles = getComputedStyle(this._element)
const num = name => Number.parseFloat(styles.getPropertyValue(name)) || 0
// These are the shipped, `--bs-`-prefixed custom properties (the build
// prefixes every custom property), not the bare names used in the SCSS source.
return (num('--bs-carousel-items') || 1) === 1 &&
num('--bs-carousel-items-peek') === 0 &&
!this._element.classList.contains(CLASS_NAME_CENTER) &&
!this._element.classList.contains(CLASS_NAME_AUTO)
}
_direction(from, to) {
const isNext = to > from
if (isRTL()) {
return isNext ? DIRECTION_RIGHT : DIRECTION_LEFT
}
return isNext ? DIRECTION_LEFT : DIRECTION_RIGHT
}
_scheduleAutoplay(index = this._activeIndex) {
const interval = this._itemInterval(index)
// Expose the wait so the active indicator's CSS fill matches it.
this._element.style.setProperty(PROPERTY_INTERVAL, `${interval}ms`)
this._interval = setTimeout(() => {
// Capture the slide the advance lands on *before* navigating: the active
// index only updates once the scroll settles (asynchronously), so reading
// it after `nextWhenVisible()` would schedule the next wait from the slide
// we're leaving — making per-item `data-bs-interval`s lag by one slide.
const upcoming = this._upcomingIndex()
this.nextWhenVisible()
// Nothing comes after the last slide when `ends: 'stop'`; stop cycling
// instead of re-arming a timer that can never advance.
if (upcoming === null) {
this.pause()
return
}
this._scheduleAutoplay(upcoming)
}, interval)
}
// The slide the next autoplay tick will rest on, derived from the live scroll
// position (which still reflects the current slide when the timer fires).
// Returns `null` when there's nowhere left to advance (`ends: stop` at the end).
_upcomingIndex() {
return this._normalizeIndex(this._navIndex() + 1, this._getItems().length)
}
_itemInterval(index = this._activeIndex) {
const item = this._getItems()[index]
const interval = item ? Number.parseInt(item.getAttribute('data-bs-interval'), 10) : Number.NaN
return Number.isNaN(interval) ? this._config.interval : interval
}
_maybeEnableCycle() {
if (!this._playing) {
return
}
this.cycle()
}
// Turn autoplay off for good once the user interacts with the carousel
_pauseFromInteraction() {
this._playing = false
this.pause()
this._updatePlayPauseControl()
}
_togglePlayPause() {
if (this._playing) {
this._pauseFromInteraction()
return
}
this._playing = true
this.cycle()
this._updatePlayPauseControl()
}
_updatePlayPauseControl() {
if (!this._playPauseElement) {
return
}
this._playPauseElement.classList.toggle(CLASS_NAME_PAUSED, !this._playing)
const label = this._playPauseElement.getAttribute(
this._playing ? 'data-bs-pause-label' : 'data-bs-play-label'
)
if (label) {
this._playPauseElement.setAttribute('aria-label', label)
}
}
_isFade() {
return this._element.classList.contains(CLASS_NAME_FADE)
}
_prefersReducedMotion() {
return typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
_getItems() {
return SelectorEngine.find(SELECTOR_ITEM, this._element)
}
_clearInterval() {
if (this._interval) {
clearTimeout(this._interval)
this._interval = null
}
}
}
/**
* Data API implementation
*/
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, function (event) {
const target = SelectorEngine.getElementFromSelector(this)
if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) {
return
}
event.preventDefault()
const carousel = Carousel.getOrCreateInstance(target)
// Manually cycling the carousel is an explicit interaction, so stop autoplay
carousel._pauseFromInteraction()
const slideIndex = this.getAttribute('data-bs-slide-to')
if (slideIndex) {
carousel.to(slideIndex)
return
}
if (Manipulator.getDataAttribute(this, 'slide') === 'next') {
carousel.next()
return
}
carousel.prev()
})
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_PLAY_PAUSE, function (event) {
const target = SelectorEngine.getElementFromSelector(this)
if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) {
return
}
event.preventDefault()
Carousel.getOrCreateInstance(target)._togglePlayPause()
})
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
const carousels = SelectorEngine.find(SELECTOR_DATA_AUTOPLAY)
for (const carousel of carousels) {
Carousel.getOrCreateInstance(carousel)
}
})
export default Carousel