From: Mark Otto Date: Thu, 11 Jun 2026 16:46:59 +0000 (-0700) Subject: Carousel: scroll-snap rebuild, opt-in autoplay, play/pause, and UI redesign (#42484) X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=816e1441198a41c41d25c5a0916a2155c107a463;p=thirdparty%2Fbootstrap.git Carousel: scroll-snap rebuild, opt-in autoplay, play/pause, and UI redesign (#42484) * Make carousel autoplay opt-in via boolean `autoplay` option Replace the `ride` option with a boolean `autoplay` option (default `false`), so carousels are static by default and only autoplay when explicitly opted in with `data-bs-autoplay="true"`. This removes the confusing `ride="true"` behavior that started autoplaying only after the first user interaction, and the cryptic `ride="carousel"` string value. Addresses the first two points of #32649. * Stop carousel autoplay on interaction and add play/pause control Address the WCAG 2.2.2 (Pause, Stop, Hide) concern in #32649. Behavior: once a user takes control of an autoplaying carousel — clicking a control or indicator, using the keyboard, or swiping — autoplay stops for good instead of resuming, respecting their intent. A new runtime `_playing` flag tracks autoplay intent and gates `_maybeEnableCycle()`. Control: add a `.carousel-control-play-pause` button as the discoverable pause/stop mechanism WCAG actually requires (a hover-only pause does not qualify). It toggles autoplay, reflects state by swapping its icon (via pause-fill/play-fill CSS mask icons) and `aria-label`, and can also start autoplay on an otherwise static carousel. Includes unit tests, SCSS tokens/styles with dark-mode support, docs, and a migration note. * Rebuild carousel on CSS scroll snap Replace the float/translateX engine and custom swipe handler with a native horizontal scroll-snap container. Sliding, touch dragging, momentum, and keyboard scrolling now come from the browser; JavaScript only layers on autoplay, the prev/next/indicator controls, and active-slide syncing via an IntersectionObserver. This unlocks features the old engine couldn't do, all via CSS custom properties on `.carousel`: - Multiple slides per view (`--carousel-items`) with responsive `.carousel-items-*` utilities - Peek of adjacent slides (`--carousel-peek`) and gaps (`--carousel-gap`) - Variable-width slides (`.carousel-auto`) and center mode (`.carousel-center`) `.carousel-fade` becomes a stacked-opacity mode that upgrades to a View Transition where supported. `touch: false` now applies `touch-action: pan-y`. The markup, public JS API, and slide/slid events are preserved; the transitional `.carousel-item-{start,end,next,prev}` classes and the `.carousel.pointer-event` helper are removed. Includes a full unit-test rewrite, SCSS engine, docs with multi-item/peek/ variable-width examples, and a migration entry. * Add carousel stacked layout and dot indicator variants Rename the per-slide layout tokens for clarity and add new layout options: - `--carousel-peek` -> `--carousel-items-peek`, `--carousel-gap` -> `--carousel-items-gap` (both now carry a length unit so they're safe inside the flex-basis `calc()`) - New `--carousel-gap` for spacing the stacked layout's rows - New `.carousel-stacked` layout (controls outside the inner viewport, above or below) and `.carousel-indicators-dots` round indicator style - Fix the `.carousel-item` flex-basis so the peek isn't subtracted twice (the `padding-inline` already insets the content box) * Add carousel `ends` option (stop/wrap/loop) and robust scroll navigation Replace the `wrap` boolean with an `ends` option taking `stop`, `wrap` (default), or `loop`: - `stop` disables the prev/next controls at each end (moving focus to the opposite control first, so focus is never lost) - `wrap` jumps from the last slide back to the first (and vice versa) - `loop` continues seamlessly into a transient clone of the destination slide, then teleports to the real one with no visible backward jump (single-slide scroll layouts only; otherwise falls back to `wrap`) Rework programmatic navigation to scroll the viewport directly with `scrollBy` instead of `scrollIntoView`, suspending scroll-snap for the duration and restoring it once the scroll settles. This fixes multi-slide jumps (indicator clicks, reaching the last slide) that `scroll-snap-stop: always` previously clamped to one slide, avoids yanking the page to an off-screen carousel, and works in RTL. Navigation steps are now measured from the live scroll position so a stale active index can't make a control silently no-op. Includes unit tests covering the ends modes, the loop transition, end-control disabling, and the scroll-settle behavior. * Document carousel end behavior, stacked layout, and indicator variants Add docs and examples for the new stacked carousels (top, bottom, and with dot indicators), the `ends` option (wrap/stop/loop) under a new "End behavior" section, and refresh the options table and intro for the scroll-snap model. * Redesign carousel controls, indicators, and layout - `.carousel` is now a flex column by default (controls/indicators sit in the flow above or below the slides); overlaying them on top of the slides moves to a new `.carousel-overlay` modifier. - Rename the control-icon classes to `.carousel-icon-{prev,next,pause,play}`, painted with `currentcolor` so they inherit the surrounding text/button color and work inside `.btn-*`. `.carousel-control-play-pause` is now just a behavior hook that shows the matching glyph via `.paused`. - Redesign indicators as pills: the active one widens, and while autoplaying it fills like a progress bar over the slide's interval (`carousel-indicator-progress` keyframes driven by `--carousel-interval`). - Remove `.carousel-caption` and its `--carousel-caption-*` tokens. * Add carousel autoplay progress, drop `touch` option and View Transitions - While cycling, toggle a `.carousel-playing` class and expose the wait as `--carousel-interval` so CSS can animate the active indicator's progress fill. Schedule each tick from the slide being navigated *to*, so per-item `data-bs-interval`s don't lag a slide behind. - Remove the `touch` option: horizontal dragging is part of the native scroll-snap container, so there's nothing left for JavaScript to toggle. - Fade is now a plain CSS opacity crossfade; drop the View Transition path, which double-animated against the CSS transition and visibly stuttered. * Update carousel docs, examples, and fixtures for the redesign Switch the docs examples, homepage/cheatsheet examples, and test fixtures to `.carousel-overlay` + `.carousel-icon-*` and drop `.carousel-caption`. Document the stacked-by-default layout, the `.carousel-overlay` modifier, the renamed control-icon classes, the autoplay progress indicator, and the removed `touch` option and captions in the migration guide. * fix bundlewatch * Fix MDX emphasis-style lint error in migration guide * Default carousel `ends` to `loop` Change the default end behavior from `wrap` to `loop`, so carousels scroll seamlessly past the ends by default (falling back to `wrap` where seamless looping doesn't apply). Tests that exercise the wrap jump now request it explicitly. * Clean up carousel styles and refresh docs - Remove the legacy `--carousel-control-*` tokens and the entire `carousel-dark-*` token map / `.carousel-dark` block and dark color-mode rule; dark styling now rides on `light-dark()` in `.carousel-overlay`. - Flip RTL prev/next icons with `transform: scaleX(-1)` instead of swapping mask images, and drop the dead commented-out overlay rules. - Drop the now-unused `colors` and `color-mode` Sass imports. - Docs: lead the end-behavior section with `loop` (the new default), rework the dark example onto `.carousel-overlay-controls` + `.btn-*` controls, and round the slide corners. * Remove unused `$enable-dark-mode` Sass flag Its last consumer was the carousel's dark color-mode block, now removed in favor of `light-dark()`, so the flag is no longer referenced anywhere. * Clean up carousel autoplay timer and listeners on dispose Clear the pending autoplay timer in `dispose()` so a queued tick can't fire after teardown and throw on the now-null `_element`, and remove the `pointerdown` listener bound to the viewport (`.carousel-inner`)—which `super.dispose()` doesn't cover, since it only drops listeners on `_element`. Adds tests for both. * Simplify carousel active indicator tokens Drop the `--carousel-indicator-active-width` and `--carousel-indicator-active-bg` tokens, which only carried `null` fallbacks, and inline their values directly on the active indicator. * Use button controls in carousel examples, fixtures, and docs Replace the removed `.carousel-control-prev`/`.carousel-control-next` classes with `.btn-icon btn-sm` buttons across the docs examples, homepage and cheatsheet examples, and JS test fixtures. Also drop the leftover `.carousel-indicators-dots` class and the obsolete "Custom transition" docs section, and correct the peek/gap token names (`--carousel-items-peek`/`-gap`) in the migration guide. * Remove `$enable-dark-mode` from the customization docs Follow-up to removing the now-unused flag: drop its row from the global options table and the sentence about disabling dark mode via Sass. * Bump JS bundlewatch size limits for the carousel rewrite * Harden carousel navigation, looping, and autoplay edge cases Audit fixes: - Measure the "current" slide in `to()` from the live scroll position (`_navIndex()`) rather than the async `_activeIndex`, so an indicator/control used mid-scroll compares against where the viewport actually rests. - Validate the `ends` option in `_configAfterMerge`, falling back to the default (`loop`) for unknown values so navigation and end-control logic agree. - Strip ids from the entire cloned subtree during a loop transition, not just the clone root, to avoid duplicate ids while the clone is on screen. - Sync the active slide and fire `slid` after a programmatic scroll settles when no IntersectionObserver is available. - Stop autoplay at the last slide under `ends: 'stop'` instead of re-arming a timer that can never advance. Adds tests for each, plus hover-pause and dispose-mid-loop coverage. * Remove unused `--carousel-indicator-opacity` token The redesigned pill indicators no longer fade via opacity, so the token has no remaining consumers. * Expand carousel docs, examples, and migration notes - Document the `ends` option, the removed `.carousel-control-*` / `.carousel-dark` classes and v5 tokens, and the removed `$enable-dark-mode` flag in the migration guide. - Clarify the `.active` starting-slide and indicator notes, theme the overlay example controls (`.btn-subtle .theme-secondary`), rework the per-item interval example with a play/pause control, and sharpen the `slid` event description. - Add a play/pause control to the integration fixture, and fix a corrupted custom-property reference in the homepage carousel example CSS. * Bump JS bundlewatch limits for carousel audit fixes --- diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 25c981393a..ebb04f0058 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -26,27 +26,27 @@ }, { "path": "./dist/css/bootstrap.css", - "maxSize": "49.25 kB" + "maxSize": "49.75 kB" }, { "path": "./dist/css/bootstrap.min.css", - "maxSize": "46.0 kB" + "maxSize": "46.25 kB" }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "72.75 kB" + "maxSize": "79.5 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "51.0 kB" + "maxSize": "51.75 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "44.0 kB" + "maxSize": "50.75 kB" }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "28.75 kB" + "maxSize": "29.75 kB" } ], "ci": { diff --git a/js/src/carousel.js b/js/src/carousel.js index 66d5b7c499..e9887b15ef 100644 --- a/js/src/carousel.js +++ b/js/src/carousel.js @@ -9,14 +9,7 @@ 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 { - getNextActiveElement, - isRTL, - isVisible, - reflow, - triggerTransitionEnd -} from './util/index.js' -import Swipe from './util/swipe.js' +import { isRTL, isVisible } from './util/index.js' /** * Constants @@ -29,10 +22,7 @@ const DATA_API_KEY = '.data-api' const ARROW_LEFT_KEY = 'ArrowLeft' const ARROW_RIGHT_KEY = 'ArrowRight' -const TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch -const ORDER_NEXT = 'next' -const ORDER_PREV = 'prev' const DIRECTION_LEFT = 'left' const DIRECTION_RIGHT = 'right' @@ -41,47 +31,74 @@ 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_DRAG_START = `dragstart${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_SLIDE = 'slide' -const CLASS_NAME_END = 'carousel-item-end' -const CLASS_NAME_START = 'carousel-item-start' -const CLASS_NAME_NEXT = 'carousel-item-next' -const CLASS_NAME_PREV = 'carousel-item-prev' +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' + +// How many frames the scroll-settle watcher waits when no movement is ever +// detected (clamped programmatic scroll, or `scrollBy` stubbed in tests) before +// it gives up and restores snapping anyway. +const SCROLL_SETTLE_MAX_FRAMES = 10 + +// 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' -const SELECTOR_ITEM = '.carousel-item' +// 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_ITEM_IMG = '.carousel-item img' +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_RIDE = '[data-bs-ride="carousel"]' +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', - ride: false, - touch: true, - wrap: true + pause: 'hover' } const DefaultType = { + autoplay: 'boolean', + ends: 'string', interval: 'number', keyboard: 'boolean', - pause: '(string|boolean)', - ride: '(boolean|string)', - touch: 'boolean', - wrap: 'boolean' + pause: '(string|boolean)' } /** @@ -92,18 +109,41 @@ 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._activeElement = null - this._isSliding = false - this.touchTimeout = null - this._swipeHelper = null + this._observer = null + this._snapRestoreFrame = 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._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element) this._addEventListeners() + this._observeItems() + this._refreshActiveState() - if (this._config.ride === CLASS_NAME_CAROUSEL) { + if (this._playing) { this.cycle() } + + this._updatePlayPauseControl() } // Getters @@ -121,84 +161,134 @@ class Carousel extends BaseComponent { // Public next() { - this._slide(ORDER_NEXT) + this.to(this._navIndex() + 1) } nextWhenVisible() { - // Don't call next when the page isn't visible - // or the carousel or its parent isn't visible + // Don't advance when the page or the carousel isn't visible if (document.visibilityState === 'visible' && isVisible(this._element)) { this.next() } } prev() { - this._slide(ORDER_PREV) + this.to(this._navIndex() - 1) } pause() { - if (this._isSliding) { - triggerTransitionEnd(this._element) - } - 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._updateInterval() - - this._interval = setInterval(() => this.nextWhenVisible(), this._config.interval) + this._scheduleAutoplay() + this._element.classList.add(CLASS_NAME_PLAYING) } - _maybeEnableCycle() { - if (!this._config.ride) { + to(index) { + // Ignore navigation while a seamless loop transition is animating + if (this._looping) { return } - if (this._isSliding) { - EventHandler.one(this._element, EVENT_SLID, () => this.cycle()) - 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 + } } - this.cycle() - } + 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() - to(index) { - const items = this._getItems() - if (index > items.length - 1 || index < 0) { + if (targetIndex === null || targetIndex === currentIndex) { return } - if (this._isSliding) { - EventHandler.one(this._element, EVENT_SLID, () => this.to(index)) + const slideEvent = EventHandler.trigger(this._element, EVENT_SLIDE, { + relatedTarget: items[targetIndex], + direction: this._direction(currentIndex, targetIndex), + from: currentIndex, + to: targetIndex + }) + + if (slideEvent.defaultPrevented) { return } - const activeIndex = this._getItemIndex(this._getActive()) - if (activeIndex === index) { + if (this._isFade()) { + this._fadeTo(targetIndex) return } - const order = index > activeIndex ? ORDER_NEXT : ORDER_PREV - - this._slide(order, items[index]) + // Scroll mode: the IntersectionObserver fires `slid` and syncs state once + // the new slide settles into view. + this._scrollToIndex(targetIndex) } dispose() { - if (this._swipeHelper) { - this._swipeHelper.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._snapRestoreFrame !== null) { + cancelAnimationFrame(this._snapRestoreFrame) + } + + // 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) { - config.defaultInterval = config.interval + 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)) @@ -209,196 +299,591 @@ class Carousel extends BaseComponent { EventHandler.on(this._element, EVENT_MOUSELEAVE, () => this._maybeEnableCycle()) } - if (this._config.touch && Swipe.isSupported()) { - this._addTouchEventListeners() - } + // Dragging, swiping, or tapping the track is an explicit interaction + EventHandler.on(this._viewport, EVENT_POINTERDOWN, () => this._pauseFromInteraction()) } - _addTouchEventListeners() { - for (const img of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) { - EventHandler.on(img, EVENT_DRAG_START, event => event.preventDefault()) + _keydown(event) { + if (/input|textarea/i.test(event.target.tagName)) { + return } - const endCallBack = () => { - if (this._config.pause !== 'hover') { - return + const direction = KEY_TO_DIRECTION[event.key] + if (direction) { + event.preventDefault() + this._pauseFromInteraction() + if (direction === DIRECTION_RIGHT) { + this.prev() + } else { + this.next() } + } + } - // If it's a touch-enabled device, mouseenter/leave are fired as - // part of the mouse compatibility events on first tap - the carousel - // would stop cycling until user tapped out of it; - // here, we listen for touchend, explicitly pause the carousel - // (as if it's the second time we tap on it, mouseenter compat event - // is NOT fired) and after a timeout (to allow for mouse compatibility - // events to fire) we explicitly restart cycling - - this.pause() - if (this.touchTimeout) { - clearTimeout(this.touchTimeout) - } + _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 + } - this.touchTimeout = setTimeout(() => this._maybeEnableCycle(), TOUCHEVENT_COMPAT_WAIT + this._config.interval) + for (const entry of entries) { + this._visibility.set(entry.target, entry.isIntersecting ? entry.intersectionRatio : 0) } - const swipeConfig = { - leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)), - rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)), - endCallback: endCallBack + 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._swipeHelper = new Swipe(this._element, swipeConfig) + 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() } - _keydown(event) { - if (/input|textarea/i.test(event.target.tagName)) { + // 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 direction = KEY_TO_DIRECTION[event.key] - if (direction) { - event.preventDefault() - this._slide(this._directionToOrder(direction)) + const left = this._scrollDelta(item) + if (Math.abs(left) < 1) { + return } + + // `scroll-snap-stop: always` keeps user wheel/touch flings to a single slide, + // but it also clamps *programmatic* scrolls to one snap point — which would + // break multi-slide jumps from an indicator click, `to()`, or wrapping from + // the last slide back to the first. Disable snapping for the duration of the + // programmatic scroll, then restore it once the scroll settles so the slide + // still rests precisely (honouring peek/gap). + const targetLeft = this._viewport.scrollLeft + left + this._viewport.style.scrollSnapType = 'none' + this._viewport.scrollBy({ + left, + top: 0, + // `'instant'` (not `'auto'`) for reduced motion: the viewport sets + // `scroll-behavior: smooth` in CSS, and `'auto'` defers to it, so it would + // still animate. `'instant'` forces an immediate, motion-free jump. + behavior: this._prefersReducedMotion() ? 'instant' : 'smooth' + }) + this._restoreSnapWhenSettled(targetLeft, index) } - _getItemIndex(element) { - return this._getItems().indexOf(element) + // 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) } - _setActiveIndicatorElement(index) { - if (!this._indicatorsElement) { + // 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 } - const activeIndicator = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement) + 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') + } - activeIndicator.classList.remove(CLASS_NAME_ACTIVE) - activeIndicator.removeAttribute('aria-current') + clone.setAttribute('aria-hidden', 'true') + clone.inert = true - const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement) + this._viewport.style.scrollSnapType = 'none' - if (newActiveIndicator) { - newActiveIndicator.classList.add(CLASS_NAME_ACTIVE) - newActiveIndicator.setAttribute('aria-current', 'true') + 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])) } - } - _updateInterval() { - const element = this._activeElement || this._getActive() + this._viewport.scrollBy({ + left: this._scrollDelta(clone), + top: 0, + behavior: 'smooth' + }) + + this._afterScrollSettles(() => { + // 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 + }) + } - if (!element) { - return + _loopDirection(isNext) { + if (isRTL()) { + return isNext ? DIRECTION_RIGHT : DIRECTION_LEFT } - const elementInterval = Number.parseInt(element.getAttribute('data-bs-interval'), 10) + 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' }) + } + + // Re-enable scroll snapping once the viewport reaches `targetLeft` (or stops + // moving). Passing the target matters: restoring `mandatory` snapping re-snaps + // to the *nearest* snap point, so if we restored mid-animation the viewport + // could jump back to the slide we came from — most visible stepping to the + // first/last slide, where it looks like the control "doesn't work". + _restoreSnapWhenSettled(targetLeft, index) { + this._afterScrollSettles(() => { + 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 && index !== undefined) { + this._setActive(index) + } - this._config.interval = elementInterval || this._config.defaultInterval + // The IntersectionObserver doesn't fire once the viewport has stopped, so + // refresh the end controls here to catch the final ~1px settle landing + // exactly on the scroll extent (e.g. disabling `next` at the last view). + this._updateEndControls() + }, targetLeft) } - _slide(order, element = null) { - if (this._isSliding) { + // Invoke `callback` once the viewport stops moving. We watch the scroll + // position across frames instead of relying on the `scrollend` event, which + // isn't available across our supported browsers yet. + // + // Crucially, we only start counting "stable" frames once the scroll has + // actually moved. A smooth `scrollBy` doesn't update `scrollLeft` for the first + // frame or two, so naively treating those initial unchanged frames as + // "settled" would re-enable `mandatory` snapping mid-animation — which cancels + // the in-flight programmatic scroll and lands on the wrong slide (most visible + // in multi-item layouts). If the scroll never moves (delta clamped at an end, + // or `scrollBy` stubbed out in unit tests), we fall back to a short frame cap. + // + // When `targetLeft` is known we also finish the moment we arrive there, so the + // snap is restored exactly on the destination snap point and can't re-snap the + // viewport backwards (the failure mode where stepping to the first/last slide + // appears to do nothing). + _afterScrollSettles(callback, targetLeft) { + if (typeof requestAnimationFrame === 'undefined') { + callback() return } - const activeElement = this._getActive() - const isNext = order === ORDER_NEXT - const nextElement = element || getNextActiveElement(this._getItems(), activeElement, isNext, this._config.wrap) + if (this._snapRestoreFrame !== null) { + cancelAnimationFrame(this._snapRestoreFrame) + } + + const startLeft = this._viewport.scrollLeft + let lastLeft = startLeft + let stableFrames = 0 + let waited = 0 + let hasMoved = false + + const tick = () => { + const currentLeft = this._viewport.scrollLeft + const reachedTarget = targetLeft !== undefined && Math.abs(currentLeft - targetLeft) <= 1 + + if (Math.abs(currentLeft - startLeft) > 1) { + hasMoved = true + } + + // Only accrue stable frames after movement begins, so the pre-animation + // and ease-in frames don't prematurely count as settled. + if (hasMoved) { + stableFrames = Math.abs(currentLeft - lastLeft) < 1 ? stableFrames + 1 : 0 + } + + lastLeft = currentLeft + waited += 1 + + if (reachedTarget || (hasMoved && stableFrames >= 3) || (!hasMoved && waited >= SCROLL_SETTLE_MAX_FRAMES)) { + this._snapRestoreFrame = null + callback() + return + } + + this._snapRestoreFrame = requestAnimationFrame(tick) + } + + this._snapRestoreFrame = requestAnimationFrame(tick) + } + + // 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) + } - if (nextElement === activeElement) { + _setActive(index) { + const items = this._getItems() + if (index === this._activeIndex || !items[index]) { return } - const nextElementIndex = this._getItemIndex(nextElement) + const from = this._activeIndex - const triggerEvent = eventName => { - return EventHandler.trigger(this._element, eventName, { - relatedTarget: nextElement, - direction: this._orderToDirection(order), - from: this._getItemIndex(activeElement), - to: nextElementIndex - }) + 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) } - const slideEvent = triggerEvent(EVENT_SLIDE) + this._setActiveIndicatorElement(this._activeIndex) + this._updateEndControls() + } - if (slideEvent.defaultPrevented) { + _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 } - if (!activeElement || !nextElement) { - // Some weirdness is happening, so we bail - 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 } - const isCycling = Boolean(this._interval) - this.pause() + 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 }) + } - this._isSliding = true + control.disabled = disabled + } + } - this._setActiveIndicatorElement(nextElementIndex) - this._activeElement = nextElement + _setActiveIndicatorElement(index) { + if (!this._indicatorsElement) { + return + } - const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END - const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV + const active = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement) + if (active) { + active.classList.remove(CLASS_NAME_ACTIVE) + active.removeAttribute('aria-current') + } - nextElement.classList.add(orderClassName) + const newActive = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement) + if (newActive) { + newActive.classList.add(CLASS_NAME_ACTIVE) + newActive.setAttribute('aria-current', 'true') + } + } - reflow(nextElement) + _normalizeIndex(index, length) { + if (Number.isNaN(index) || length === 0) { + return null + } - activeElement.classList.add(directionalClassName) - nextElement.classList.add(directionalClassName) + if (index < 0) { + return this._wrapsAround() ? length - 1 : null + } - const completeCallBack = () => { - nextElement.classList.remove(directionalClassName, orderClassName) - nextElement.classList.add(CLASS_NAME_ACTIVE) + if (index > length - 1) { + return this._wrapsAround() ? 0 : null + } - activeElement.classList.remove(CLASS_NAME_ACTIVE, orderClassName, directionalClassName) + return index + } - this._isSliding = false + // 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 + } - triggerEvent(EVENT_SLID) + // 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 } - this._queueCallback(completeCallBack, activeElement, this._isAnimated()) + const styles = getComputedStyle(this._element) + const num = name => Number.parseFloat(styles.getPropertyValue(name)) || 0 - if (isCycling) { - this.cycle() + // 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 } - _isAnimated() { - return this._element.classList.contains(CLASS_NAME_SLIDE) + _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) } - _getActive() { - return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element) + // 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) } - _getItems() { - return SelectorEngine.find(SELECTOR_ITEM, this._element) + _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 } - _clearInterval() { - if (this._interval) { - clearInterval(this._interval) - this._interval = null + _maybeEnableCycle() { + if (!this._playing) { + return } + + this.cycle() } - _directionToOrder(direction) { - if (isRTL()) { - return direction === DIRECTION_LEFT ? ORDER_PREV : ORDER_NEXT + // 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 } - return direction === DIRECTION_LEFT ? ORDER_NEXT : ORDER_PREV + this._playing = true + this.cycle() + this._updatePlayPauseControl() } - _orderToDirection(order) { - if (isRTL()) { - return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT + _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) } + } - return order === ORDER_PREV ? DIRECTION_RIGHT : DIRECTION_LEFT + _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 + } } } @@ -416,26 +901,39 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, function (e 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) - carousel._maybeEnableCycle() return } if (Manipulator.getDataAttribute(this, 'slide') === 'next') { carousel.next() - carousel._maybeEnableCycle() return } carousel.prev() - carousel._maybeEnableCycle() +}) + +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_RIDE) + const carousels = SelectorEngine.find(SELECTOR_DATA_AUTOPLAY) for (const carousel of carousels) { Carousel.getOrCreateInstance(carousel) diff --git a/js/tests/integration/index.html b/js/tests/integration/index.html index e4fa9f0a38..dffe29fc2b 100644 --- a/js/tests/integration/index.html +++ b/js/tests/integration/index.html @@ -19,45 +19,40 @@ Tooltip on top - diff --git a/js/tests/unit/carousel.spec.js b/js/tests/unit/carousel.spec.js index 3204fa66e1..86d7285584 100644 --- a/js/tests/unit/carousel.spec.js +++ b/js/tests/unit/carousel.spec.js @@ -1,37 +1,107 @@ import Carousel from '../../src/carousel.js' import EventHandler from '../../src/dom/event-handler.js' -import { isRTL, noop } from '../../src/util/index.js' -import Swipe from '../../src/util/swipe.js' import { clearFixture, createEvent, getFixture } from '../helpers/fixture.js' describe('Carousel', () => { - const { Simulator, PointerEvent } = window - const originWinPointerEvent = PointerEvent - const supportPointerEvent = Boolean(PointerEvent) + let fixtureEl + let realIntersectionObserver + let scrollBySpy + + // A no-op IntersectionObserver so the real one doesn't fire during tests; + // active-slide syncing is driven explicitly via `_handleIntersection`. + class MockIntersectionObserver { + constructor(callback, options = {}) { + this.callback = callback + this.root = options.root ?? null + this.thresholds = options.threshold ?? [] + } - const cssStyleCarousel = '.carousel.pointer-event { touch-action: none; }' + observe() {} - const stylesCarousel = document.createElement('style') - stylesCarousel.type = 'text/css' - stylesCarousel.append(document.createTextNode(cssStyleCarousel)) + unobserve() {} - const clearPointerEvents = () => { - window.PointerEvent = null + disconnect() {} } - const restorePointerEvents = () => { - window.PointerEvent = originWinPointerEvent + const basicMarkup = ({ classes = 'carousel slide', autoplay = false, indicators = false } = {}) => { + const autoplayAttr = autoplay ? ' data-bs-autoplay="true"' : '' + const indicatorsMarkup = indicators ? + [ + ' ' + ].join('') : + '' + + return [ + `
`, + indicatorsMarkup, + ' ', + '
' + ].join('') } - let fixtureEl + // Give the viewport and items a deterministic horizontal layout so the + // bounding-rect maths in `_scrollToIndex` produces predictable scroll deltas. + // (Unit tests run without the carousel CSS, so real layout would collapse + // every item to the same x and yield a zero delta.) + const stubLayout = (carousel, { viewportLeft = 0, viewportWidth = 300, itemWidth = 100, gap = 0 } = {}) => { + const rect = (left, width) => ({ + left, width, right: left + width, top: 0, bottom: 0, height: 0, x: left, y: 0, toJSON() {} + }) + + spyOn(carousel._viewport, 'getBoundingClientRect').and.returnValue(rect(viewportLeft, viewportWidth)) + + for (const [index, item] of carousel._getItems().entries()) { + spyOn(item, 'getBoundingClientRect').and.returnValue(rect(viewportLeft + (index * (itemWidth + gap)), itemWidth)) + } + } + + const intersect = (carousel, ratios) => { + const items = carousel._getItems() + const entries = items.map((target, index) => ({ + target, + isIntersecting: (ratios[index] ?? 0) > 0, + intersectionRatio: ratios[index] ?? 0 + })) + carousel._handleIntersection(entries) + } + + // `scrollBy` is stubbed (no real movement), so the settle watcher never sees + // movement and falls back to its frame cap (SCROLL_SETTLE_MAX_FRAMES = 10). + // Await past that so the loop transition's teleport/`slid` step runs first. + const flushFrames = (count = 14) => { + let chain = Promise.resolve() + for (let i = 0; i < count; i++) { + chain = chain.then(() => new Promise(resolve => { + requestAnimationFrame(resolve) + })) + } + + return chain + } beforeAll(() => { fixtureEl = getFixture() }) + beforeEach(() => { + realIntersectionObserver = window.IntersectionObserver + window.IntersectionObserver = MockIntersectionObserver + scrollBySpy = spyOn(Element.prototype, 'scrollBy') + }) + afterEach(() => { + window.IntersectionObserver = realIntersectionObserver + document.documentElement.dir = '' clearFixture() }) @@ -45,6 +115,22 @@ describe('Carousel', () => { it('should return plugin default config', () => { expect(Carousel.Default).toEqual(jasmine.any(Object)) }) + + it('should default autoplay to false and pause to hover', () => { + expect(Carousel.Default.autoplay).toBeFalse() + expect(Carousel.Default.pause).toEqual('hover') + }) + + it('should default `ends` to `loop`', () => { + expect(Carousel.Default.ends).toEqual('loop') + }) + + it('should fall back to the default `ends` for unknown values', () => { + fixtureEl.innerHTML = basicMarkup() + + const carousel = new Carousel('#myCarousel', { ends: 'nope' }) + expect(carousel._config.ends).toEqual('loop') + }) }) describe('DATA_KEY', () => { @@ -55,7 +141,7 @@ describe('Carousel', () => { describe('constructor', () => { it('should take care of element either passed as a CSS selector or DOM element', () => { - fixtureEl.innerHTML = '' + fixtureEl.innerHTML = basicMarkup() const carouselEl = fixtureEl.querySelector('#myCarousel') const carouselBySelector = new Carousel('#myCarousel') @@ -65,1448 +151,1298 @@ describe('Carousel', () => { expect(carouselByElement._element).toEqual(carouselEl) }) - it('should start cycling if `ride`===`carousel`', () => { - fixtureEl.innerHTML = '' + it('should find the scroll viewport, indicators and play/pause control', () => { + fixtureEl.innerHTML = basicMarkup({ indicators: true }) const carousel = new Carousel('#myCarousel') - expect(carousel._interval).not.toBeNull() - }) - it('should not start cycling if `ride`!==`carousel`', () => { - fixtureEl.innerHTML = '' - - const carousel = new Carousel('#myCarousel') - expect(carousel._interval).toBeNull() + expect(carousel._viewport).toEqual(fixtureEl.querySelector('.carousel-inner')) + expect(carousel._indicatorsElement).toEqual(fixtureEl.querySelector('.carousel-indicators')) }) - it('should go to next item if right arrow key is pressed', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const carouselEl = fixtureEl.querySelector('#myCarousel') - const carousel = new Carousel(carouselEl, { - keyboard: true - }) - - const spy = spyOn(carousel, '_keydown').and.callThrough() - - carouselEl.addEventListener('slid.bs.carousel', () => { - expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2')) - expect(spy).toHaveBeenCalled() - resolve() - }) - - const keydown = createEvent('keydown') - keydown.key = 'ArrowRight' - - carouselEl.dispatchEvent(keydown) - }) - }) - - it('should ignore keyboard events if data-bs-keyboard=false', () => { + it('should set the initial active index from the active item', () => { fixtureEl.innerHTML = [ - '', - ' ', - ' ', - ' ', - '' - ].join('') + describe('navigation', () => { + it('should scroll the viewport to the next item and fire `slide`', () => { + fixtureEl.innerHTML = basicMarkup() const carouselEl = fixtureEl.querySelector('#myCarousel') - const input = fixtureEl.querySelector('input') - const textarea = fixtureEl.querySelector('textarea') - const carousel = new Carousel(carouselEl, { - keyboard: true - }) - - const spyKeydown = spyOn(carousel, '_keydown').and.callThrough() - const spySlide = spyOn(carousel, '_slide') + const carousel = new Carousel(carouselEl) + stubLayout(carousel) + const slideSpy = jasmine.createSpy('slide') + EventHandler.on(carouselEl, 'slide.bs.carousel', slideSpy) - const keydown = createEvent('keydown', { bubbles: true, cancelable: true }) - keydown.key = 'ArrowRight' - Object.defineProperty(keydown, 'target', { - value: input, - writable: true, - configurable: true - }) + carousel.next() - input.dispatchEvent(keydown) + expect(scrollBySpy).toHaveBeenCalled() + // The viewport (not the item) is the element being scrolled + expect(scrollBySpy.calls.mostRecent().object).toEqual(carousel._viewport) + // item2 sits one item-width (100) to the right of the viewport's start + expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(100) + expect(slideSpy).toHaveBeenCalledTimes(1) + expect(slideSpy.calls.mostRecent().args[0].to).toEqual(1) + expect(slideSpy.calls.mostRecent().args[0].direction).toEqual('left') + }) - expect(spyKeydown).toHaveBeenCalled() - expect(spySlide).not.toHaveBeenCalled() + it('should disable scroll snapping for the duration of the programmatic scroll', () => { + fixtureEl.innerHTML = basicMarkup() - spyKeydown.calls.reset() - spySlide.calls.reset() + const carousel = new Carousel('#myCarousel') + stubLayout(carousel) - Object.defineProperty(keydown, 'target', { - value: textarea - }) - textarea.dispatchEvent(keydown) + carousel.next() - expect(spyKeydown).toHaveBeenCalled() - expect(spySlide).not.toHaveBeenCalled() + // `scroll-snap-stop: always` would otherwise clamp the scroll to a single + // slide, so snapping is turned off while we drive the viewport. + expect(carousel._viewport.style.scrollSnapType).toEqual('none') }) - it('should not slide if arrow key is pressed and carousel is sliding', () => { - fixtureEl.innerHTML = '
' + it('should not scroll when the `slide` event is prevented', () => { + fixtureEl.innerHTML = basicMarkup() - const carouselEl = fixtureEl.querySelector('div') - const carousel = new Carousel(carouselEl, {}) + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl) + stubLayout(carousel) + EventHandler.on(carouselEl, 'slide.bs.carousel', event => event.preventDefault()) - const spy = spyOn(EventHandler, 'trigger') + carousel.next() - carousel._isSliding = true + expect(scrollBySpy).not.toHaveBeenCalled() + }) - for (const key of ['ArrowLeft', 'ArrowRight']) { - const keydown = createEvent('keydown') - keydown.key = key + it('should wrap to the last item when going prev from the first (wrap: true)', () => { + fixtureEl.innerHTML = basicMarkup() - carouselEl.dispatchEvent(keydown) - } + const carousel = new Carousel('#myCarousel', { ends: 'wrap' }) + stubLayout(carousel) + carousel.prev() - expect(spy).not.toHaveBeenCalled() + // item3 is two item-widths (200) to the right — a full multi-slide jump + expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200) }) - it('should wrap around from end to start when wrap option is true', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should not move past the ends when `ends` is `stop`', () => { + fixtureEl.innerHTML = basicMarkup() - const carouselEl = fixtureEl.querySelector('#myCarousel') - const carousel = new Carousel(carouselEl, { wrap: true }) - const getActiveId = () => carouselEl.querySelector('.carousel-item.active').getAttribute('id') - - carouselEl.addEventListener('slid.bs.carousel', event => { - const activeId = getActiveId() - - if (activeId === 'two') { - carousel.next() - return - } - - if (activeId === 'three') { - carousel.next() - return - } - - if (activeId === 'one') { - // carousel wrapped around and slid from 3rd to 1st slide - expect(activeId).toEqual('one') - expect(event.from + 1).toEqual(3) - resolve() - } - }) - - carousel.next() - }) + const carousel = new Carousel('#myCarousel', { ends: 'stop' }) + stubLayout(carousel) + carousel.prev() + + expect(scrollBySpy).not.toHaveBeenCalled() }) - it('should stay at the start when the prev method is called and wrap is false', () => { - return new Promise((resolve, reject) => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should center the active slide when `.carousel-center` is present', () => { + fixtureEl.innerHTML = basicMarkup({ classes: 'carousel slide carousel-center' }) - const carouselEl = fixtureEl.querySelector('#myCarousel') - const firstElement = fixtureEl.querySelector('#one') - const carousel = new Carousel(carouselEl, { wrap: false }) + const carousel = new Carousel('#myCarousel') + stubLayout(carousel, { viewportWidth: 300, itemWidth: 200 }) + carousel.next() + + // Center mode aligns the item's center to the viewport's center: + // (itemLeft 200 + itemWidth/2 100) - (viewportLeft 0 + viewportWidth/2 150) = 150 + // (start alignment would instead be itemLeft - viewportLeft = 200) + expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(150) + }) - carouselEl.addEventListener('slid.bs.carousel', () => { - reject(new Error('carousel slid when it should not have slid')) - }) + it('should rest the slide at the scroll-padding (peek) offset', () => { + fixtureEl.innerHTML = basicMarkup() - carousel.prev() + const carousel = new Carousel('#myCarousel') + stubLayout(carousel) + // Emulate `--bs-carousel-items-peek` via the resulting scroll-padding so + // the slide settles where snapping will (no secondary snap afterwards). + carousel._viewport.style.scrollPaddingInlineStart = '30px' + carousel.next() - setTimeout(() => { - expect(firstElement).toHaveClass('active') - resolve() - }, 10) - }) + // itemLeft 100 - (viewportLeft 0 + padStart 30) = 70 (vs 100 without peek) + expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(70) }) - it('should not add touch event listeners if touch = false', () => { - fixtureEl.innerHTML = '
' + it('should do nothing when navigating to the current index', () => { + fixtureEl.innerHTML = basicMarkup() - const carouselEl = fixtureEl.querySelector('div') + const carousel = new Carousel('#myCarousel') + stubLayout(carousel) + carousel.to(0) - const spy = spyOn(Carousel.prototype, '_addTouchEventListeners') + expect(scrollBySpy).not.toHaveBeenCalled() + }) - const carousel = new Carousel(carouselEl, { - touch: false - }) + it('should not advance past the last item when `ends` is `stop`', () => { + fixtureEl.innerHTML = basicMarkup() - expect(spy).not.toHaveBeenCalled() - expect(carousel._swipeHelper).toBeNull() + const carousel = new Carousel('#myCarousel', { ends: 'stop' }) + stubLayout(carousel) + carousel._activeIndex = 2 + carousel.next() + + expect(scrollBySpy).not.toHaveBeenCalled() }) - it('should not add touch event listeners if touch supported = false', () => { - fixtureEl.innerHTML = '
' + it('should ignore a non-numeric index', () => { + fixtureEl.innerHTML = basicMarkup() - const carouselEl = fixtureEl.querySelector('div') - spyOn(Swipe, 'isSupported').and.returnValue(false) + const carousel = new Carousel('#myCarousel') + stubLayout(carousel) + carousel.to('not-a-number') - const carousel = new Carousel(carouselEl) - EventHandler.off(carouselEl, Carousel.EVENT_KEY) + expect(scrollBySpy).not.toHaveBeenCalled() + }) + + it('should scroll without smooth behavior when reduced motion is preferred', () => { + fixtureEl.innerHTML = basicMarkup() - const spy = spyOn(carousel, '_addTouchEventListeners') + spyOn(window, 'matchMedia').and.returnValue({ matches: true }) - carousel._addEventListeners() + const carousel = new Carousel('#myCarousel') + stubLayout(carousel) + carousel.next() - expect(spy).not.toHaveBeenCalled() - expect(carousel._swipeHelper).toBeNull() + // `'instant'` rather than `'auto'`, which would defer to the CSS + // `scroll-behavior: smooth` and animate despite the user's preference. + expect(scrollBySpy.calls.mostRecent().args[0].behavior).toEqual('instant') }) - it('should add touch event listeners by default', () => { - fixtureEl.innerHTML = '
' + it('should not scroll when the target item is missing', () => { + fixtureEl.innerHTML = basicMarkup() - const carouselEl = fixtureEl.querySelector('div') + const carousel = new Carousel('#myCarousel') + stubLayout(carousel) + carousel._scrollToIndex(99) + + expect(scrollBySpy).not.toHaveBeenCalled() + }) - spyOn(Carousel.prototype, '_addTouchEventListeners') + it('should report mirrored slide directions in RTL', () => { + fixtureEl.innerHTML = basicMarkup() + document.documentElement.dir = 'rtl' - // Headless browser does not support touch events, so need to fake it - // to test that touch events are add properly. - document.documentElement.ontouchstart = noop + const carouselEl = fixtureEl.querySelector('#myCarousel') const carousel = new Carousel(carouselEl) + stubLayout(carousel) + const slideSpy = jasmine.createSpy('slide') + EventHandler.on(carouselEl, 'slide.bs.carousel', slideSpy) - expect(carousel._addTouchEventListeners).toHaveBeenCalled() - }) - - it('should allow swiperight and call _slide (prev) with pointer events', () => { - return new Promise(resolve => { - if (!supportPointerEvent) { - expect().nothing() - resolve() - return - } - - document.documentElement.ontouchstart = noop - document.head.append(stylesCarousel) - Simulator.setType('pointer') - - fixtureEl.innerHTML = [ - '' - ].join('') - - const carouselEl = fixtureEl.querySelector('.carousel') - const item = fixtureEl.querySelector('#item') - const carousel = new Carousel(carouselEl) + carousel.next() + expect(slideSpy.calls.mostRecent().args[0].direction).toEqual('right') - const spy = spyOn(carousel, '_slide').and.callThrough() - - carouselEl.addEventListener('slid.bs.carousel', event => { - expect(item).toHaveClass('active') - expect(spy).toHaveBeenCalledWith('prev') - expect(event.direction).toEqual('right') - stylesCarousel.remove() - delete document.documentElement.ontouchstart - resolve() - }) - - Simulator.gestures.swipe(carouselEl, { - deltaX: 300, - deltaY: 0 - }) - }) + carousel._activeIndex = 1 + carousel.to(0) + expect(slideSpy.calls.mostRecent().args[0].direction).toEqual('left') }) - it('should allow swipeleft and call next with pointer events', () => { - return new Promise(resolve => { - if (!supportPointerEvent) { - expect().nothing() - resolve() - return - } - - document.documentElement.ontouchstart = noop - document.head.append(stylesCarousel) - Simulator.setType('pointer') - - fixtureEl.innerHTML = [ - '' - ].join('') - - const carouselEl = fixtureEl.querySelector('.carousel') - const item = fixtureEl.querySelector('#item') - const carousel = new Carousel(carouselEl) + it('should step from the live scroll position, not a stale active index', () => { + fixtureEl.innerHTML = basicMarkup() - const spy = spyOn(carousel, '_slide').and.callThrough() - - carouselEl.addEventListener('slid.bs.carousel', event => { - expect(item).not.toHaveClass('active') - expect(spy).toHaveBeenCalledWith('next') - expect(event.direction).toEqual('left') - stylesCarousel.remove() - delete document.documentElement.ontouchstart - resolve() - }) - - Simulator.gestures.swipe(carouselEl, { - pos: [300, 10], - deltaX: -300, - deltaY: 0 - }) + const carousel = new Carousel('#myCarousel') + const rect = (left, width) => ({ + left, width, right: left + width, top: 0, bottom: 0, height: 0, x: left, y: 0, toJSON() {} }) - }) - it('should allow swiperight and call _slide (prev) with touch events', () => { - return new Promise(resolve => { - Simulator.setType('touch') - clearPointerEvents() - document.documentElement.ontouchstart = noop - - fixtureEl.innerHTML = [ - '' - ].join('') - - const carouselEl = fixtureEl.querySelector('.carousel') - const item = fixtureEl.querySelector('#item') - const carousel = new Carousel(carouselEl) + // Scrollable layout currently resting with item 2 (index 2) at the start. + Object.defineProperty(carousel._viewport, 'scrollWidth', { configurable: true, get: () => 900 }) + Object.defineProperty(carousel._viewport, 'clientWidth', { configurable: true, get: () => 300 }) + spyOn(carousel._viewport, 'getBoundingClientRect').and.returnValue(rect(0, 300)) + for (const [index, item] of carousel._getItems().entries()) { + spyOn(item, 'getBoundingClientRect').and.returnValue(rect((index - 2) * 100, 100)) + } - const spy = spyOn(carousel, '_slide').and.callThrough() - - carouselEl.addEventListener('slid.bs.carousel', event => { - expect(item).toHaveClass('active') - expect(spy).toHaveBeenCalledWith('prev') - expect(event.direction).toEqual('right') - delete document.documentElement.ontouchstart - restorePointerEvents() - resolve() - }) - - Simulator.gestures.swipe(carouselEl, { - deltaX: 300, - deltaY: 0 - }) - }) + // The IntersectionObserver hasn't caught up, so the tracked index is stale. + carousel._activeIndex = 0 + + // Navigation must measure from the real position (item 2), not the stale 0. + expect(carousel._navIndex()).toEqual(2) }) - it('should allow swipeleft and call _slide (next) with touch events', () => { - return new Promise(resolve => { - Simulator.setType('touch') - clearPointerEvents() - document.documentElement.ontouchstart = noop - - fixtureEl.innerHTML = [ - '' - ].join('') - - const carouselEl = fixtureEl.querySelector('.carousel') - const item = fixtureEl.querySelector('#item') - const carousel = new Carousel(carouselEl) + it('should cancel a pending snap-restore frame when navigating again', () => { + fixtureEl.innerHTML = basicMarkup() - const spy = spyOn(carousel, '_slide').and.callThrough() - - carouselEl.addEventListener('slid.bs.carousel', event => { - expect(item).not.toHaveClass('active') - expect(spy).toHaveBeenCalledWith('next') - expect(event.direction).toEqual('left') - delete document.documentElement.ontouchstart - restorePointerEvents() - resolve() - }) - - Simulator.gestures.swipe(carouselEl, { - pos: [300, 10], - deltaX: -300, - deltaY: 0 - }) - }) - }) + const carousel = new Carousel('#myCarousel') + stubLayout(carousel) + const cancelSpy = spyOn(window, 'cancelAnimationFrame').and.callThrough() - it('should not slide when swiping and carousel is sliding', () => { - return new Promise(resolve => { - Simulator.setType('touch') - clearPointerEvents() - document.documentElement.ontouchstart = noop - - fixtureEl.innerHTML = [ - '' - ].join('') - - const carouselEl = fixtureEl.querySelector('.carousel') - const carousel = new Carousel(carouselEl) - carousel._isSliding = true - - const spy = spyOn(EventHandler, 'trigger') - - Simulator.gestures.swipe(carouselEl, { - deltaX: 300, - deltaY: 0 - }) - - Simulator.gestures.swipe(carouselEl, { - pos: [300, 10], - deltaX: -300, - deltaY: 0 - }) - - setTimeout(() => { - expect(spy).not.toHaveBeenCalled() - delete document.documentElement.ontouchstart - restorePointerEvents() - resolve() - }, 300) - }) + carousel.next() // schedules a snap-restore frame + carousel.to(2) // navigates again before it settles, cancelling the pending frame + + expect(cancelSpy).toHaveBeenCalled() }) - it('should not allow pinch with touch events', () => { - return new Promise(resolve => { - Simulator.setType('touch') - clearPointerEvents() - document.documentElement.ontouchstart = noop + it('should restore snapping immediately when requestAnimationFrame is unavailable', () => { + fixtureEl.innerHTML = basicMarkup() - fixtureEl.innerHTML = '' + const carousel = new Carousel('#myCarousel') + stubLayout(carousel) + const original = window.requestAnimationFrame + window.requestAnimationFrame = undefined - const carouselEl = fixtureEl.querySelector('.carousel') - const carousel = new Carousel(carouselEl) + carousel.next() + expect(carousel._viewport.style.scrollSnapType).toEqual('') - Simulator.gestures.swipe(carouselEl, { - pos: [300, 10], - deltaX: -300, - deltaY: 0, - touches: 2 - }, () => { - restorePointerEvents() - delete document.documentElement.ontouchstart - expect(carousel._swipeHelper._deltaX).toEqual(0) - resolve() - }) - }) + window.requestAnimationFrame = original }) - it('should call pause method on mouse over with pause equal to hover', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' + it('should restore snapping as soon as the viewport reaches the scroll target', async () => { + fixtureEl.innerHTML = basicMarkup() - const carouselEl = fixtureEl.querySelector('.carousel') - const carousel = new Carousel(carouselEl) - - const spy = spyOn(carousel, 'pause') + const carousel = new Carousel('#myCarousel') + carousel._viewport.style.scrollSnapType = 'none' + // Pretend the viewport is already sitting on the destination snap point. + Object.defineProperty(carousel._viewport, 'scrollLeft', { configurable: true, get: () => 300 }) - const mouseOverEvent = createEvent('mouseover') - carouselEl.dispatchEvent(mouseOverEvent) + carousel._restoreSnapWhenSettled(300) + // Far fewer than SCROLL_SETTLE_MAX_FRAMES: only the target-reached branch + // can restore this quickly (the no-movement fallback waits the full cap). + await flushFrames(2) - setTimeout(() => { - expect(spy).toHaveBeenCalled() - resolve() - }, 10) - }) + expect(carousel._viewport.style.scrollSnapType).toEqual('') }) + }) - it('should call `maybeEnableCycle` on mouse out with pause equal to hover', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '' + describe('end behavior (`ends`)', () => { + it('should not move past either end when `ends` is `stop`', () => { + fixtureEl.innerHTML = basicMarkup() - const carouselEl = fixtureEl.querySelector('.carousel') - const carousel = new Carousel(carouselEl) + const carousel = new Carousel('#myCarousel', { ends: 'stop' }) + stubLayout(carousel) - const spyEnable = spyOn(carousel, '_maybeEnableCycle').and.callThrough() - const spyCycle = spyOn(carousel, 'cycle') + carousel.prev() + expect(scrollBySpy).not.toHaveBeenCalled() - const mouseOutEvent = createEvent('mouseout') - carouselEl.dispatchEvent(mouseOutEvent) + carousel._activeIndex = 2 + carousel.next() + expect(scrollBySpy).not.toHaveBeenCalled() + }) - setTimeout(() => { - expect(spyEnable).toHaveBeenCalled() - expect(spyCycle).toHaveBeenCalled() - resolve() - }, 10) - }) + it('should wrap around at both ends when `ends` is `wrap`', () => { + fixtureEl.innerHTML = basicMarkup() + + const carousel = new Carousel('#myCarousel', { ends: 'wrap' }) + stubLayout(carousel) + + carousel.prev() + // wraps to the last item (item3), two item-widths (200) to the right + expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200) }) }) - describe('next', () => { - it('should not slide if the carousel is sliding', () => { - fixtureEl.innerHTML = '
' - - const carouselEl = fixtureEl.querySelector('div') - const carousel = new Carousel(carouselEl, {}) + describe('seamless loop (`ends: loop`)', () => { + it('should continue into a transient clone and teleport to the first slide when going next from the last', async () => { + fixtureEl.innerHTML = basicMarkup() - const spy = spyOn(EventHandler, 'trigger') + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, { ends: 'loop' }) + stubLayout(carousel) + const slidSpy = jasmine.createSpy('slid') + EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy) - carousel._isSliding = true + carousel._activeIndex = 2 carousel.next() - expect(spy).not.toHaveBeenCalled() + // A clone is appended for the duration of the animation + expect(carousel._viewport.querySelector('.carousel-item-clone')).not.toBeNull() + expect(carousel._looping).toBeTrue() + + await flushFrames() + + expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull() + expect(carousel._activeIndex).toEqual(0) + expect(carousel._looping).toBeFalse() + expect(slidSpy.calls.mostRecent().args[0].to).toEqual(0) + expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('left') }) - it('should not fire slid when slide is prevented', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = '
' + it('should continue into a transient clone and teleport to the last slide when going prev from the first', async () => { + fixtureEl.innerHTML = basicMarkup() - const carouselEl = fixtureEl.querySelector('div') - const carousel = new Carousel(carouselEl, {}) - let slidEvent = false + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, { ends: 'loop' }) + stubLayout(carousel) + const slidSpy = jasmine.createSpy('slid') + EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy) - const doneTest = () => { - setTimeout(() => { - expect(slidEvent).toBeFalse() - resolve() - }, 20) - } + carousel.prev() - carouselEl.addEventListener('slide.bs.carousel', event => { - event.preventDefault() - doneTest() - }) + expect(carousel._viewport.querySelector('.carousel-item-clone')).not.toBeNull() - carouselEl.addEventListener('slid.bs.carousel', () => { - slidEvent = true - }) + await flushFrames() - carousel.next() - }) + expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull() + expect(carousel._activeIndex).toEqual(2) + expect(slidSpy.calls.mostRecent().args[0].to).toEqual(2) + expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('right') }) - it('should fire slide event with: direction, relatedTarget, from and to', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should report mirrored loop directions in RTL', async () => { + fixtureEl.innerHTML = basicMarkup() + document.documentElement.dir = 'rtl' - const carouselEl = fixtureEl.querySelector('#myCarousel') - const carousel = new Carousel(carouselEl, {}) + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, { ends: 'loop' }) + stubLayout(carousel) + const slidSpy = jasmine.createSpy('slid') + EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy) - const onSlide = event => { - expect(event.direction).toEqual('left') - expect(event.relatedTarget).toHaveClass('carousel-item') - expect(event.from).toEqual(0) - expect(event.to).toEqual(1) + carousel._activeIndex = 2 + carousel.next() + await flushFrames() - carouselEl.removeEventListener('slide.bs.carousel', onSlide) - carouselEl.addEventListener('slide.bs.carousel', onSlide2) + expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('right') + }) - carousel.prev() - } + it('should not start a transition when the slide event is prevented', () => { + fixtureEl.innerHTML = basicMarkup() - const onSlide2 = event => { - expect(event.direction).toEqual('right') - resolve() - } + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, { ends: 'loop' }) + stubLayout(carousel) + EventHandler.on(carouselEl, 'slide.bs.carousel', event => event.preventDefault()) - carouselEl.addEventListener('slide.bs.carousel', onSlide) - carousel.next() - }) + carousel.prev() + + expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull() + expect(carousel._looping).toBeFalse() }) - it('should fire slid event with: direction, relatedTarget, from and to', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should ignore re-entrant navigation while a transition is in flight', () => { + fixtureEl.innerHTML = basicMarkup() - const carouselEl = fixtureEl.querySelector('#myCarousel') - const carousel = new Carousel(carouselEl, {}) + const carousel = new Carousel('#myCarousel', { ends: 'loop' }) + stubLayout(carousel) + carousel._looping = true - const onSlid = event => { - expect(event.direction).toEqual('left') - expect(event.relatedTarget).toHaveClass('carousel-item') - expect(event.from).toEqual(0) - expect(event.to).toEqual(1) + carousel.to(1) - carouselEl.removeEventListener('slid.bs.carousel', onSlid) - carouselEl.addEventListener('slid.bs.carousel', onSlid2) + expect(scrollBySpy).not.toHaveBeenCalled() + }) - carousel.prev() - } + it('should not move the active index from intersection churn while looping', () => { + fixtureEl.innerHTML = basicMarkup() - const onSlid2 = event => { - expect(event.direction).toEqual('right') - resolve() - } + const carousel = new Carousel('#myCarousel', { ends: 'loop' }) + stubLayout(carousel) + carousel._activeIndex = 0 + carousel._looping = true - carouselEl.addEventListener('slid.bs.carousel', onSlid) - carousel.next() - }) + intersect(carousel, [0, 0, 1]) + + expect(carousel._activeIndex).toEqual(0) }) - it('should update the active element to the next item before sliding', () => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should fall back to a plain wrap for multi-item layouts', () => { + fixtureEl.innerHTML = basicMarkup() const carouselEl = fixtureEl.querySelector('#myCarousel') - const secondItemEl = fixtureEl.querySelector('#secondItem') - const carousel = new Carousel(carouselEl) + carouselEl.style.setProperty('--bs-carousel-items', '2') + const carousel = new Carousel(carouselEl, { ends: 'loop' }) + stubLayout(carousel) - carousel.next() + carousel.prev() + + expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull() + expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200) + }) - expect(carousel._activeElement).toEqual(secondItemEl) + it('should fall back to a plain wrap for centered layouts', () => { + fixtureEl.innerHTML = basicMarkup({ classes: 'carousel slide carousel-center' }) + + const carousel = new Carousel('#myCarousel', { ends: 'loop' }) + stubLayout(carousel) + + carousel.prev() + + expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull() + expect(scrollBySpy).toHaveBeenCalled() }) - it('should continue cycling if it was already', () => { + it('should fall back to a plain wrap with fewer than two slides', () => { fixtureEl.innerHTML = [ '' ].join('') + const carousel = new Carousel('#myCarousel', { ends: 'loop' }) + stubLayout(carousel) + + carousel.prev() + + expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull() + }) + + it('should fall back to a plain wrap under reduced motion', () => { + fixtureEl.innerHTML = basicMarkup() + spyOn(window, 'matchMedia').and.returnValue({ matches: true }) + + const carousel = new Carousel('#myCarousel', { ends: 'loop' }) + stubLayout(carousel) + + carousel.prev() + + expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull() + expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200) + }) + + it('should fall back to a plain wrap when a peek is configured', () => { + fixtureEl.innerHTML = basicMarkup() const carouselEl = fixtureEl.querySelector('#myCarousel') - const carousel = new Carousel(carouselEl) - const spy = spyOn(carousel, 'cycle') + carouselEl.style.setProperty('--bs-carousel-items-peek', '16px') - carousel.next() - expect(spy).not.toHaveBeenCalled() + const carousel = new Carousel(carouselEl, { ends: 'loop' }) + stubLayout(carousel) - carousel.cycle() - carousel.next() - expect(spy).toHaveBeenCalledTimes(1) - }) - - it('should update indicators if present', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '' - ].join('') + carousel.prev() - const carouselEl = fixtureEl.querySelector('#myCarousel') - const firstIndicator = fixtureEl.querySelector('#firstIndicator') - const secondIndicator = fixtureEl.querySelector('#secondIndicator') - const carousel = new Carousel(carouselEl) + expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull() + }) - carouselEl.addEventListener('slid.bs.carousel', () => { - expect(firstIndicator).not.toHaveClass('active') - expect(firstIndicator.hasAttribute('aria-current')).toBeFalse() - expect(secondIndicator).toHaveClass('active') - expect(secondIndicator.getAttribute('aria-current')).toEqual('true') - resolve() - }) + it('should fall back to a plain wrap for variable-width (`.carousel-auto`) layouts', () => { + fixtureEl.innerHTML = basicMarkup({ classes: 'carousel slide carousel-auto' }) - carousel.next() - }) + const carousel = new Carousel('#myCarousel', { ends: 'loop' }) + stubLayout(carousel) + + carousel.prev() + + expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull() }) + }) - it('should call next()/prev() instance methods when clicking the respective direction buttons', () => { - fixtureEl.innerHTML = [ - '`} /> + +## End behavior + +The `ends` option controls what happens at the first and last slide. Set it with `data-bs-ends` (or the `ends` option in JavaScript), choosing from `loop` (the default), `wrap`, or `stop`. + +### Loop (default) + +With `data-bs-ends="loop"` the carousel scrolls seamlessly past the ends: stepping next from the last slide (or previous from the first) continues in the same direction into the destination slide instead of jumping back, for an endless conveyor effect. This is the default, so it applies with or without the attribute. Seamless looping applies to single-slide carousels driven by the controls, keyboard, or autoplay; multi-item, peek, centered, and variable-width layouts—as well as users who prefer reduced motion—fall back to the `wrap` jump. + + +
+
Loop
+
+ + +
+
+ + `} /> + +### Wrap + +With `data-bs-ends="wrap"` the carousel jumps from the last slide back to the first, and vice versa, so the controls never reach a dead end. + + +
+
Wrap around
+
+ + +
+
+ `} /> -## Disable touch swiping - -Carousels support swiping left/right on touchscreen devices to move between slides. This can be disabled by setting the `touch` option to `false`. - - - + + - - `} /> ## Dark carousel -Add `data-bs-theme="dark"` to the `.carousel` for darker controls, indicators, and captions. Controls are inverted compared to their default white fill with the `filter` CSS property. Captions and controls have additional Sass variables that customize the `color` and `background-color`. +Add `data-bs-theme="dark"` to the `.carousel` for darker controls and indicators. This is typically most useful with overlay carousels when you need the reverse contrast against the background of the slides. - - + + - - `} /> -## Custom transition - -The transition duration of `.carousel-item` can be changed with the `$carousel-transition-duration` Sass variable before compiling or custom styles if you’re using the compiled CSS. If multiple transitions are applied, make sure the transform transition is defined first (e.g. `transition: transform 2s ease, opacity .5s ease-out`). - ## CSS ### Variables @@ -329,10 +545,6 @@ Tokens for all carousels: -Tokens for the [dark carousel](#dark-carousel): - - - ## Usage ### Via data attributes @@ -342,10 +554,13 @@ Use data attributes to easily control the position of the carousel. `data-bs-sli | Attribute | Description | | --- | --- | -| `data-bs-ride` | Starts autoplay after the first manual cycle (`true`) or on load (`carousel`). | +| `data-bs-autoplay` | Set to `true` to autoplay the carousel on load. | | `data-bs-slide` | `prev` or `next` to move the carousel relative to the current slide. | | `data-bs-slide-to` | Zero-based index of the slide to show. | | `data-bs-interval` | Autoplay interval in milliseconds between slides. | +| `data-bs-ends` | How the carousel behaves at its ends: `stop`, `wrap`, or `loop`. | +| `data-bs-play-label` | On a `.carousel-control-play-pause` button, the `aria-label` to apply while autoplay is stopped. | +| `data-bs-pause-label` | On a `.carousel-control-play-pause` button, the `aria-label` to apply while autoplay is playing. | ### Via JavaScript @@ -363,26 +578,27 @@ const carousel = new bootstrap.Carousel('#myCarousel') | Name | Type | Default | Description | | --- | --- | --- | --- | +| `autoplay` | boolean | `false` | If set to `true`, autoplays the carousel on load. Otherwise the carousel does not autoplay. | +| `ends` | string | `"loop"` | How the carousel behaves at its ends. `"stop"` hard-stops at the first/last slide—and disables the previous control on the first slide and the next control on the last (via the native `disabled` attribute on `