// `--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
+// 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
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
*/
this._interval = null
this._observer = null
- this._snapRestoreFrame = 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._observer.disconnect()
}
- if (this._snapRestoreFrame !== null) {
- cancelAnimationFrame(this._snapRestoreFrame)
+ if (this._scrollFrame !== null) {
+ cancelAnimationFrame(this._scrollFrame)
}
// Tidy up any in-flight loop transition: drop a stray clone and restore
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).
+ // `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._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._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()
})
- this._restoreSnapWhenSettled(targetLeft, index)
+ }
+
+ // 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
this._jumpScroll(this._scrollDelta(items[fromIndex]))
}
- this._viewport.scrollBy({
- left: this._scrollDelta(clone),
- top: 0,
- behavior: 'smooth'
- })
-
- this._afterScrollSettles(() => {
+ 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).
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)
- }
-
- // 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)
- }
-
- // 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
- }
-
- 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`
describe('Carousel', () => {
let fixtureEl
let realIntersectionObserver
- let scrollBySpy
+ let scrollToSpy
+ let animateScrollSpy
+ // Completion callback captured from the most recent stubbed `_animateScroll`,
+ // so a test can decide when the (otherwise instant in tests) scroll "settles".
+ let pendingScrollComplete
+
+ // Run the pending scroll's completion callback (clone teleport, active sync,
+ // snap restore, …), simulating the animation finishing.
+ const settleScroll = () => {
+ const complete = pendingScrollComplete
+ pendingScrollComplete = null
+ if (complete) {
+ complete()
+ }
+ }
// A no-op IntersectionObserver so the real one doesn't fire during tests;
// active-slide syncing is driven explicitly via `_handleIntersection`.
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')
+ // Stub `scrollBy` (used by the loop transition's instant teleport) so it
+ // doesn't move the fixture; assertions go through `scrollToSpy`/`animateScrollSpy`.
+ spyOn(Element.prototype, 'scrollBy')
+ scrollToSpy = spyOn(Element.prototype, 'scrollTo')
+ // Stub the JS scroll: record the target and capture the completion callback
+ // (run on demand via `settleScroll`) instead of waiting out the real rAF
+ // animation. Tests that exercise the real animator opt back in with
+ // `animateScrollSpy.and.callThrough()`.
+ pendingScrollComplete = null
+ animateScrollSpy = spyOn(Carousel.prototype, '_animateScroll').and.callFake((targetLeft, onComplete) => {
+ pendingScrollComplete = onComplete
+ })
})
afterEach(() => {
carousel.next()
- 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(animateScrollSpy).toHaveBeenCalled()
+ // item2 sits one item-width (100) to the right of the viewport's start, so
+ // the absolute scroll target is 0 + 100
+ expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(100)
expect(slideSpy).toHaveBeenCalledTimes(1)
expect(slideSpy.calls.mostRecent().args[0].to).toEqual(1)
expect(slideSpy.calls.mostRecent().args[0].direction).toEqual('left')
carousel.next()
- expect(scrollBySpy).not.toHaveBeenCalled()
+ expect(animateScrollSpy).not.toHaveBeenCalled()
})
it('should wrap to the last item when going prev from the first (wrap: true)', () => {
carousel.prev()
// item3 is two item-widths (200) to the right — a full multi-slide jump
- expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+ expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(200)
})
it('should not move past the ends when `ends` is `stop`', () => {
stubLayout(carousel)
carousel.prev()
- expect(scrollBySpy).not.toHaveBeenCalled()
+ expect(animateScrollSpy).not.toHaveBeenCalled()
})
it('should center the active slide when `.carousel-center` is present', () => {
// 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)
+ expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(150)
})
it('should rest the slide at the scroll-padding (peek) offset', () => {
carousel.next()
// itemLeft 100 - (viewportLeft 0 + padStart 30) = 70 (vs 100 without peek)
- expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(70)
+ expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(70)
})
it('should do nothing when navigating to the current index', () => {
stubLayout(carousel)
carousel.to(0)
- expect(scrollBySpy).not.toHaveBeenCalled()
+ expect(animateScrollSpy).not.toHaveBeenCalled()
})
it('should not advance past the last item when `ends` is `stop`', () => {
carousel._activeIndex = 2
carousel.next()
- expect(scrollBySpy).not.toHaveBeenCalled()
+ expect(animateScrollSpy).not.toHaveBeenCalled()
})
it('should ignore a non-numeric index', () => {
stubLayout(carousel)
carousel.to('not-a-number')
- expect(scrollBySpy).not.toHaveBeenCalled()
- })
-
- it('should scroll without smooth behavior when reduced motion is preferred', () => {
- fixtureEl.innerHTML = basicMarkup()
-
- spyOn(window, 'matchMedia').and.returnValue({ matches: true })
-
- const carousel = new Carousel('#myCarousel')
- stubLayout(carousel)
- carousel.next()
-
- // `'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')
+ expect(animateScrollSpy).not.toHaveBeenCalled()
})
it('should not scroll when the target item is missing', () => {
stubLayout(carousel)
carousel._scrollToIndex(99)
- expect(scrollBySpy).not.toHaveBeenCalled()
+ expect(animateScrollSpy).not.toHaveBeenCalled()
})
it('should report mirrored slide directions in RTL', () => {
// Navigation must measure from the real position (item 2), not the stale 0.
expect(carousel._navIndex()).toEqual(2)
})
+ })
- it('should cancel a pending snap-restore frame when navigating again', () => {
+ // The real `_animateScroll` (the global spy is bypassed with `callThrough`).
+ // We drive a JS-owned rAF animation instead of `scrollBy({behavior:'smooth'})`
+ // because Safari mis-scales programmatic smooth scrolls under page zoom.
+ describe('scroll animation (`_animateScroll`)', () => {
+ // Run whatever rAF callbacks are queued, passing each a synthetic timestamp
+ // (ms) so the time-based animation advances deterministically (no real wait).
+ const tick = (frames, now) => {
+ for (const cb of frames.splice(0)) {
+ cb(now)
+ }
+ }
+
+ it('should step the viewport toward the target with eased, instant scrolls and finish on it', () => {
fixtureEl.innerHTML = basicMarkup()
+ const carousel = new Carousel('#myCarousel')
+ animateScrollSpy.and.callThrough()
+
+ const frames = []
+ spyOn(window, 'requestAnimationFrame').and.callFake(cb => frames.push(cb))
+ const onComplete = jasmine.createSpy('onComplete')
+
+ // SCROLL_DURATION is 300ms; the first frame seeds the start time.
+ carousel._animateScroll(500, onComplete)
+
+ tick(frames, 0) // progress 0 → eased position 0, instant scroll, not done
+ expect(scrollToSpy.calls.mostRecent().args[0].behavior).toEqual('instant')
+ expect(scrollToSpy.calls.mostRecent().args[0].left).toEqual(0)
+ expect(onComplete).not.toHaveBeenCalled()
+
+ tick(frames, 200) // progress ~0.67 → past the easing midpoint, partway there
+ const mid = scrollToSpy.calls.mostRecent().args[0].left
+ expect(mid).toBeGreaterThan(0)
+ expect(mid).toBeLessThan(500)
+ expect(onComplete).not.toHaveBeenCalled()
+ tick(frames, 400) // progress > 1 → land exactly on target and complete
+ expect(scrollToSpy.calls.mostRecent().args[0].left).toEqual(500)
+ expect(onComplete).toHaveBeenCalledTimes(1)
+ })
+
+ it('should cancel an in-flight animation frame when a new scroll starts', () => {
+ fixtureEl.innerHTML = basicMarkup()
const carousel = new Carousel('#myCarousel')
- stubLayout(carousel)
+ animateScrollSpy.and.callThrough()
+
+ spyOn(window, 'requestAnimationFrame').and.returnValue(42)
const cancelSpy = spyOn(window, 'cancelAnimationFrame').and.callThrough()
- carousel.next() // schedules a snap-restore frame
- carousel.to(2) // navigates again before it settles, cancelling the pending frame
+ carousel._animateScroll(100, () => {})
+ carousel._animateScroll(200, () => {}) // supersedes the first
- expect(cancelSpy).toHaveBeenCalled()
+ expect(cancelSpy).toHaveBeenCalledWith(42)
})
- it('should restore snapping immediately when requestAnimationFrame is unavailable', () => {
+ it('should jump straight to the target under reduced motion', () => {
fixtureEl.innerHTML = basicMarkup()
+ spyOn(window, 'matchMedia').and.returnValue({ matches: true })
+ const carousel = new Carousel('#myCarousel')
+ animateScrollSpy.and.callThrough()
+ const rafSpy = spyOn(window, 'requestAnimationFrame')
+ const onComplete = jasmine.createSpy('onComplete')
+
+ carousel._animateScroll(500, onComplete)
+
+ // No animation frames scheduled; one instant scroll lands on target.
+ expect(rafSpy).not.toHaveBeenCalled()
+ expect(scrollToSpy).toHaveBeenCalledTimes(1)
+ expect(scrollToSpy.calls.mostRecent().args[0]).toEqual({ left: 500, behavior: 'instant' })
+ expect(onComplete).toHaveBeenCalledTimes(1)
+ })
+
+ it('should jump straight to the target when requestAnimationFrame is unavailable', () => {
+ fixtureEl.innerHTML = basicMarkup()
const carousel = new Carousel('#myCarousel')
- stubLayout(carousel)
+ animateScrollSpy.and.callThrough()
+
const original = window.requestAnimationFrame
window.requestAnimationFrame = undefined
+ const onComplete = jasmine.createSpy('onComplete')
- carousel.next()
- expect(carousel._viewport.style.scrollSnapType).toEqual('')
+ try {
+ carousel._animateScroll(500, onComplete)
+ } finally {
+ window.requestAnimationFrame = original
+ }
- window.requestAnimationFrame = original
+ expect(scrollToSpy.calls.mostRecent().args[0]).toEqual({ left: 500, behavior: 'instant' })
+ expect(onComplete).toHaveBeenCalledTimes(1)
})
- it('should restore snapping as soon as the viewport reaches the scroll target', async () => {
+ it('should restore snapping once the navigation animation completes', () => {
fixtureEl.innerHTML = basicMarkup()
-
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 })
+ stubLayout(carousel)
+ animateScrollSpy.and.callThrough()
+ const original = window.requestAnimationFrame
+ window.requestAnimationFrame = undefined
- 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)
+ try {
+ carousel.next()
+ } finally {
+ window.requestAnimationFrame = original
+ }
+ // Snapping is suspended for the scroll, then restored on completion.
expect(carousel._viewport.style.scrollSnapType).toEqual('')
})
})
stubLayout(carousel)
carousel.prev()
- expect(scrollBySpy).not.toHaveBeenCalled()
+ expect(animateScrollSpy).not.toHaveBeenCalled()
carousel._activeIndex = 2
carousel.next()
- expect(scrollBySpy).not.toHaveBeenCalled()
+ expect(animateScrollSpy).not.toHaveBeenCalled()
})
it('should wrap around at both ends when `ends` is `wrap`', () => {
carousel.prev()
// wraps to the last item (item3), two item-widths (200) to the right
- expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+ expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(200)
})
})
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 () => {
+ it('should continue into a transient clone and teleport to the first slide when going next from the last', () => {
fixtureEl.innerHTML = basicMarkup()
const carouselEl = fixtureEl.querySelector('#myCarousel')
expect(carousel._viewport.querySelector('.carousel-item-clone')).not.toBeNull()
expect(carousel._looping).toBeTrue()
- await flushFrames()
+ settleScroll()
expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
expect(carousel._activeIndex).toEqual(0)
expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('left')
})
- it('should continue into a transient clone and teleport to the last slide when going prev from the first', async () => {
+ it('should continue into a transient clone and teleport to the last slide when going prev from the first', () => {
fixtureEl.innerHTML = basicMarkup()
const carouselEl = fixtureEl.querySelector('#myCarousel')
expect(carousel._viewport.querySelector('.carousel-item-clone')).not.toBeNull()
- await flushFrames()
+ settleScroll()
expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
expect(carousel._activeIndex).toEqual(2)
expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('right')
})
- it('should report mirrored loop directions in RTL', async () => {
+ it('should report mirrored loop directions in RTL', () => {
fixtureEl.innerHTML = basicMarkup()
document.documentElement.dir = 'rtl'
carousel._activeIndex = 2
carousel.next()
- await flushFrames()
+ settleScroll()
expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('right')
})
carousel.to(1)
- expect(scrollBySpy).not.toHaveBeenCalled()
+ expect(animateScrollSpy).not.toHaveBeenCalled()
})
it('should not move the active index from intersection churn while looping', () => {
carousel.prev()
expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
- expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+ expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(200)
})
it('should fall back to a plain wrap for centered layouts', () => {
carousel.prev()
expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
- expect(scrollBySpy).toHaveBeenCalled()
+ expect(animateScrollSpy).toHaveBeenCalled()
})
it('should fall back to a plain wrap with fewer than two slides', () => {
carousel.prev()
expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
- expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+ expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(200)
})
it('should fall back to a plain wrap when a peek is configured', () => {
expect(fixtureEl.querySelector('#next').disabled).toBeFalse()
})
- it('should refresh the end controls once a programmatic scroll settles at the extent', async () => {
+ it('should refresh the end controls once a programmatic scroll settles at the extent', () => {
fixtureEl.innerHTML = controlsMarkup({ slides: 6 })
const carousel = new Carousel('#myCarousel', { ends: 'stop' })
expect(fixtureEl.querySelector('#next').disabled).toBeFalse()
carousel._scrollToIndex(1)
- await flushFrames()
+ settleScroll()
expect(fixtureEl.querySelector('#next').disabled).toBeTrue()
})
expect(carousel._activeIndex).toEqual(1)
})
- it('should sync the active slide and fire `slid` after settling when there is no observer', async () => {
+ it('should sync the active slide and fire `slid` after settling when there is no observer', () => {
const original = window.IntersectionObserver
window.IntersectionObserver = undefined
EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy)
carousel.to(1)
- await flushFrames()
+ settleScroll()
expect(carousel._activeIndex).toEqual(1)
expect(slidSpy).toHaveBeenCalled()
expect(fixtureEl.querySelector('#item2')).toHaveClass('active')
expect(fixtureEl.querySelector('#item1')).not.toHaveClass('active')
- expect(scrollBySpy).not.toHaveBeenCalled()
+ expect(animateScrollSpy).not.toHaveBeenCalled()
})
it('should not use the View Transition API for the fade', () => {
expect(carousel._playing).toBeFalse()
expect(carousel._interval).toBeNull()
- expect(scrollBySpy).toHaveBeenCalled()
+ expect(animateScrollSpy).toHaveBeenCalled()
})
it('should go to the previous item with data-bs-slide="prev"', () => {
fixtureEl.querySelector('#prev').click()
// wraps to the last item (item2), one item-width (100) to the right
- expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(100)
+ expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(100)
})
it('should go to a given index with data-bs-slide-to', () => {
fixtureEl.querySelector('[data-bs-slide-to="2"]').click()
// item3 is two item-widths (200) to the right — a full multi-slide jump
- expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+ expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(200)
})
it('should toggle play/pause via the data-api', () => {