Rebuild Carousel on CSS scroll-snap and IntersectionObserver (#3057)

This commit is contained in:
Bartosz-Do authored and Bartek committed 2026-10-02 12:11:33 +02:00
1 parent 7f98716422
commit 9052cbfbd4
5 files changed
+289 -271

No files matched your search

+123 -91
View File
@@ -9,9 +9,8 @@ import BaseComponent from './base-component'
import EventHandler from './dom/event-handler'
import Manipulator from './dom/manipulator'
import SelectorEngine from './dom/selector-engine'
import { defineJQueryPlugin, getNextActiveElement, isRTL, isVisible, reflow, triggerTransitionEnd } from './util/index'
import { defineJQueryPlugin, getNextActiveElement, isRTL, isVisible } from './util/index'
import type { ComponentConfig as BaseComponentConfig, JQueryCollectionLike } from './types'
import Swipe from './util/swipe'
type ComponentConfig = {
interval: number | boolean
@@ -32,7 +31,6 @@ const DATA_API_KEY = '.data-api'
const ARROW_LEFT_KEY = 'ArrowLeft'
const ARROW_RIGHT_KEY = 'ArrowRight'
const TOUCHEVENT_COMPAT_WAIT = 500
const ORDER_NEXT = 'next'
const ORDER_PREV = 'prev'
@@ -44,22 +42,17 @@ 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_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 SELECTOR_ACTIVE = '.active'
const SELECTOR_ITEM = '.carousel-item'
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_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to], [data-tblr-slide], [data-tblr-slide-to]'
const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"], [data-tblr-ride="carousel"]'
@@ -93,21 +86,25 @@ class Carousel extends BaseComponent {
_interval: ReturnType<typeof setInterval> | null
_activeElement: HTMLElement | null
_isSliding: boolean
touchTimeout: ReturnType<typeof setTimeout> | null
_swipeHelper: Swipe | null
_indicatorsElement: HTMLElement | null
_viewport: HTMLElement | null
_observer: IntersectionObserver | null
_pendingElement: HTMLElement | null
constructor(element: HTMLElement | string, config?: ComponentConfigInput) {
super(element, config)
this._interval = null
this._activeElement = null
this._activeElement = this._getActive()
this._isSliding = false
this.touchTimeout = null
this._swipeHelper = null
this._observer = null
this._pendingElement = null
this._viewport = SelectorEngine.findOne(SELECTOR_INNER, this._element)
this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element)
this._addEventListeners()
this._observeItems()
if (this._config.ride === CLASS_NAME_CAROUSEL) {
this.cycle()
@@ -141,10 +138,6 @@ class Carousel extends BaseComponent {
}
pause(): void {
if (this._isSliding) {
triggerTransitionEnd(this._element)
}
this._clearInterval()
}
@@ -190,8 +183,8 @@ class Carousel extends BaseComponent {
}
dispose(): void {
if (this._swipeHelper) {
this._swipeHelper.dispose()
if (this._observer) {
this._observer.disconnect()
}
super.dispose()
@@ -212,38 +205,13 @@ class Carousel extends BaseComponent {
EventHandler.on(this._element, EVENT_MOUSELEAVE, () => this._maybeEnableCycle())
}
if (this._config.touch && Swipe.isSupported()) {
this._addTouchEventListeners()
// Scrolling is native; `touch: false` only turns off the drag gesture on the
// viewport, buttons/keyboard/indicators keep working.
if (this._viewport) {
this._viewport.style.touchAction = this._config.touch ? '' : 'none'
}
}
_addTouchEventListeners(): void {
for (const img of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) {
EventHandler.on(img, EVENT_DRAG_START, (event: Event) => event.preventDefault())
}
const endCallBack = () => {
if (this._config.pause !== 'hover') {
return
}
this.pause()
if (this.touchTimeout) {
clearTimeout(this.touchTimeout)
}
this.touchTimeout = setTimeout(() => this._maybeEnableCycle(), TOUCHEVENT_COMPAT_WAIT + (this._config.interval as number))
}
const swipeConfig = {
leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)),
rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)),
endCallback: endCallBack,
}
this._swipeHelper = new Swipe(this._element, swipeConfig)
}
_keydown(event: KeyboardEvent): void {
if (/input|textarea/i.test((event.target as HTMLElement).tagName)) {
return
@@ -299,69 +267,133 @@ class Carousel extends BaseComponent {
const isNext = order === ORDER_NEXT
const nextElement = element || getNextActiveElement<HTMLElement>(this._getItems(), activeElement!, isNext, this._config.wrap)
if (nextElement === activeElement) {
if (!activeElement || !nextElement || nextElement === activeElement) {
return
}
const nextElementIndex = this._getItemIndex(nextElement)
const triggerEvent = (eventName: string) => {
return EventHandler.trigger(this._element, eventName, {
relatedTarget: nextElement,
direction: this._orderToDirection(order),
from: this._getItemIndex(activeElement!),
to: nextElementIndex,
})
}
const slideEvent = triggerEvent(EVENT_SLIDE)
const slideEvent = EventHandler.trigger(this._element, EVENT_SLIDE, {
relatedTarget: nextElement,
direction: this._orderToDirection(order),
from: this._getItemIndex(activeElement),
to: this._getItemIndex(nextElement),
})
if (slideEvent?.defaultPrevented) {
return
}
if (!activeElement || !nextElement) {
return
}
const isCycling = Boolean(this._interval)
this.pause()
this._isSliding = true
this._setActiveIndicatorElement(nextElementIndex)
this._activeElement = nextElement
const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END
const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV
nextElement.classList.add(orderClassName)
reflow(nextElement)
activeElement.classList.add(directionalClassName)
nextElement.classList.add(directionalClassName)
const completeCallBack = () => {
nextElement.classList.remove(directionalClassName, orderClassName)
if (this._element.classList.contains(CLASS_NAME_FADE)) {
activeElement.classList.remove(CLASS_NAME_ACTIVE)
nextElement.classList.add(CLASS_NAME_ACTIVE)
this._queueCallback(() => this._finishSlide(activeElement, nextElement), nextElement, true)
} else {
// A jump of more than one item (wrap-around, `to()`) scrolls past the
// items in between; pin the target so their transient crossings are
// ignored until it actually arrives — see `_handleIntersection()`.
this._pendingElement = nextElement
activeElement.classList.remove(CLASS_NAME_ACTIVE, orderClassName, directionalClassName)
// scrollBy() on the viewport itself, not scrollIntoView(): the latter
// walks up every scrollable ancestor, so on a page taller than the
// window it would also scroll the page to chase a carousel that isn't
// fully in view.
if (this._viewport) {
const delta = nextElement.getBoundingClientRect().left - this._viewport.getBoundingClientRect().left
this._viewport.scrollBy({ left: delta })
}
this._isSliding = false
triggerEvent(EVENT_SLID)
// Safety net: don't wait on the observer forever if it never confirms
// arrival (element removed, browser quirk).
setTimeout(() => {
if (this._pendingElement === nextElement) {
this._pendingElement = null
this._setActive(nextElement)
}
}, 2000)
}
this._queueCallback(completeCallBack, activeElement, this._isAnimated())
if (isCycling) {
this.cycle()
}
}
_isAnimated(): boolean {
return this._element.classList.contains(CLASS_NAME_SLIDE)
_observeItems(): void {
// Fade carousels stack every item in one grid cell, so every item always
// intersects the viewport fully — geometry can't tell them apart.
if (!this._viewport || this._element.classList.contains(CLASS_NAME_FADE)) {
return
}
this._observer = new IntersectionObserver((entries) => this._handleIntersection(entries), {
root: this._viewport,
threshold: [0, 0.5, 1],
})
for (const item of this._getItems()) {
this._observer.observe(item)
}
}
_handleIntersection(entries: IntersectionObserverEntry[]): void {
if (this._pendingElement) {
// A programmatic jump (wrap-around, `to()`) scrolls past items in
// between; ignore their transient crossings until the actual target
// — the only one that can clear `_pendingElement` — arrives.
const arrived = entries.find((entry) => entry.target === this._pendingElement && entry.intersectionRatio >= 0.5)
if (arrived) {
this._pendingElement = null
this._setActive(arrived.target as HTMLElement)
}
return
}
// A manual swipe/scroll has no pinned target: judge only this batch, not a
// running history, so a stale ratio never outranks what arrives next.
let bestEntry: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!bestEntry || entry.intersectionRatio > bestEntry.intersectionRatio) {
bestEntry = entry
}
}
if (bestEntry && bestEntry.intersectionRatio >= 0.5) {
this._setActive(bestEntry.target as HTMLElement)
}
}
_setActive(nextElement: HTMLElement): void {
const activeElement = this._activeElement ?? this._getActive()
if (!activeElement || nextElement === activeElement) {
return
}
activeElement.classList.remove(CLASS_NAME_ACTIVE)
nextElement.classList.add(CLASS_NAME_ACTIVE)
this._finishSlide(activeElement, nextElement)
}
_finishSlide(fromElement: HTMLElement, toElement: HTMLElement): void {
const fromIndex = this._getItemIndex(fromElement)
const toIndex = this._getItemIndex(toElement)
const order = toIndex > fromIndex ? ORDER_NEXT : ORDER_PREV
this._setActiveIndicatorElement(toIndex)
this._activeElement = toElement
this._isSliding = false
EventHandler.trigger(this._element, EVENT_SLID, {
relatedTarget: toElement,
direction: this._orderToDirection(order),
from: fromIndex,
to: toIndex,
})
}
_getActive(): HTMLElement | null {
+124 -125
View File
@@ -1,8 +1,32 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import Carousel from '../../src/bootstrap/carousel'
import EventHandler from '../../src/bootstrap/dom/event-handler'
import { clearFixture, createEvent, getFixture } from '../helpers/fixture'
// The real IntersectionObserver needs actual CSS (scroll-snap, flex) to tell items
// apart, which these unit tests don't load. Mock it out — same approach as
// scrollspy.spec.ts — and drive arrival manually with `simulateArrival()`.
class MockIntersectionObserver implements IntersectionObserver {
readonly root: Element | Document | null = null
readonly rootMargin: string = ''
readonly thresholds: ReadonlyArray<number> = []
constructor(_callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.root = (options?.root as Element | null) ?? null
this.rootMargin = options?.rootMargin ?? ''
this.thresholds = Array.isArray(options?.threshold) ? options!.threshold : [options?.threshold ?? 0]
}
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
takeRecords = vi.fn().mockReturnValue([])
}
const simulateArrival = (carousel: Carousel, target: Element): void => {
carousel._handleIntersection([{ target, intersectionRatio: 1, isIntersecting: true } as unknown as IntersectionObserverEntry])
}
describe('Carousel', () => {
let fixtureEl: HTMLElement
@@ -10,9 +34,14 @@ describe('Carousel', () => {
fixtureEl = getFixture()
})
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('VERSION', () => {
@@ -60,39 +89,33 @@ describe('Carousel', () => {
})
it('should go to next item on right arrow key', () => {
return new Promise<void>((resolve) => {
fixtureEl.innerHTML = ['<div id="myCarousel" class="carousel slide">', ' <div class="carousel-inner">', ' <div class="carousel-item active">item 1</div>', ' <div id="item2" class="carousel-item">item 2</div>', ' <div class="carousel-item">item 3</div>', ' </div>', '</div>'].join('')
fixtureEl.innerHTML = ['<div id="myCarousel" class="carousel slide">', ' <div class="carousel-inner">', ' <div class="carousel-item active">item 1</div>', ' <div id="item2" class="carousel-item">item 2</div>', ' <div class="carousel-item">item 3</div>', ' </div>', '</div>'].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { keyboard: true })
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { keyboard: true })
const item2 = fixtureEl.querySelector('#item2')!
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('.active')).toBe(fixtureEl.querySelector('#item2'))
carousel.dispose()
resolve()
})
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
carouselEl.dispatchEvent(keydown)
simulateArrival(carousel, item2)
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
carouselEl.dispatchEvent(keydown)
})
expect(fixtureEl.querySelector('.active')).toBe(item2)
carousel.dispose()
})
it('should go to previous item on left arrow key', () => {
return new Promise<void>((resolve) => {
fixtureEl.innerHTML = ['<div id="myCarousel" class="carousel slide">', ' <div class="carousel-inner">', ' <div id="item1" class="carousel-item">item 1</div>', ' <div class="carousel-item active">item 2</div>', ' <div class="carousel-item">item 3</div>', ' </div>', '</div>'].join('')
fixtureEl.innerHTML = ['<div id="myCarousel" class="carousel slide">', ' <div class="carousel-inner">', ' <div id="item1" class="carousel-item">item 1</div>', ' <div class="carousel-item active">item 2</div>', ' <div class="carousel-item">item 3</div>', ' </div>', '</div>'].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { keyboard: true })
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { keyboard: true })
const item1 = fixtureEl.querySelector('#item1')!
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('.active')).toBe(fixtureEl.querySelector('#item1'))
carousel.dispose()
resolve()
})
const keydown = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
carouselEl.dispatchEvent(keydown)
simulateArrival(carousel, item1)
const keydown = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
carouselEl.dispatchEvent(keydown)
})
expect(fixtureEl.querySelector('.active')).toBe(item1)
carousel.dispose()
})
it('should not prevent keydown for non-arrow keys', () => {
@@ -141,20 +164,17 @@ describe('Carousel', () => {
describe('next', () => {
it('should slide to next item', () => {
return new Promise<void>((resolve) => {
fixtureEl.innerHTML = ['<div id="myCarousel" class="carousel slide">', ' <div class="carousel-inner">', ' <div class="carousel-item active">item 1</div>', ' <div id="item2" class="carousel-item">item 2</div>', ' </div>', '</div>'].join('')
fixtureEl.innerHTML = ['<div id="myCarousel" class="carousel slide">', ' <div class="carousel-inner">', ' <div class="carousel-item active">item 1</div>', ' <div id="item2" class="carousel-item">item 2</div>', ' </div>', '</div>'].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
const item2 = fixtureEl.querySelector('#item2')!
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item2')!.classList.contains('active')).toBe(true)
carousel.dispose()
resolve()
})
carousel.next()
simulateArrival(carousel, item2)
carousel.next()
})
expect(item2.classList.contains('active')).toBe(true)
carousel.dispose()
})
})
@@ -210,20 +230,17 @@ describe('Carousel', () => {
describe('to', () => {
it('should go to specific index', () => {
return new Promise<void>((resolve) => {
fixtureEl.innerHTML = ['<div id="myCarousel" class="carousel slide">', ' <div class="carousel-inner">', ' <div class="carousel-item active">item 1</div>', ' <div id="item2" class="carousel-item">item 2</div>', ' <div id="item3" class="carousel-item">item 3</div>', ' </div>', '</div>'].join('')
fixtureEl.innerHTML = ['<div id="myCarousel" class="carousel slide">', ' <div class="carousel-inner">', ' <div class="carousel-item active">item 1</div>', ' <div id="item2" class="carousel-item">item 2</div>', ' <div id="item3" class="carousel-item">item 3</div>', ' </div>', '</div>'].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
const item3 = fixtureEl.querySelector('#item3')!
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item3')!.classList.contains('active')).toBe(true)
carousel.dispose()
resolve()
})
carousel.to(2)
simulateArrival(carousel, item3)
carousel.to(2)
})
expect(item3.classList.contains('active')).toBe(true)
carousel.dispose()
})
it('should ignore invalid index', () => {
@@ -407,30 +424,21 @@ describe('Carousel', () => {
describe('wrap', () => {
it('should wrap from end to start', () => {
return new Promise<void>((resolve) => {
fixtureEl.innerHTML = ['<div id="myCarousel" class="carousel slide">', ' <div class="carousel-inner">', ' <div id="one" class="carousel-item active"></div>', ' <div id="two" class="carousel-item"></div>', ' <div id="three" class="carousel-item"></div>', ' </div>', '</div>'].join('')
fixtureEl.innerHTML = ['<div id="myCarousel" class="carousel slide">', ' <div class="carousel-inner">', ' <div id="one" class="carousel-item active"></div>', ' <div id="two" class="carousel-item"></div>', ' <div id="three" class="carousel-item"></div>', ' </div>', '</div>'].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { wrap: true })
let slidCount = 0
carouselEl.addEventListener('slid.bs.carousel', () => {
slidCount++
const activeId = carouselEl.querySelector('.carousel-item.active')!.id
if (slidCount < 3) {
carousel.next()
return
}
// wrapped back to first
expect(activeId).toBe('one')
carousel.dispose()
resolve()
})
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { wrap: true })
const items = [...fixtureEl.querySelectorAll('.carousel-item')]
let index = 0
for (let i = 0; i < 3; i++) {
carousel.next()
})
index = (index + 1) % items.length
simulateArrival(carousel, items[index])
}
expect(carouselEl.querySelector('.carousel-item.active')!.id).toBe('one')
carousel.dispose()
})
})
@@ -468,78 +476,69 @@ describe('Carousel', () => {
})
it('should navigate via data-tblr-slide="next"', () => {
return new Promise<void>((resolve) => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' </div>',
' <button data-tblr-slide="next" data-bs-target="#myCarousel">Next</button>',
'</div>',
].join('')
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' </div>',
' <button data-tblr-slide="next" data-bs-target="#myCarousel">Next</button>',
'</div>',
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const nextBtn = fixtureEl.querySelector('[data-tblr-slide="next"]') as HTMLElement
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const nextBtn = fixtureEl.querySelector('[data-tblr-slide="next"]') as HTMLElement
const item2 = fixtureEl.querySelector('#item2')!
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item2')!.classList.contains('active')).toBe(true)
resolve()
})
nextBtn.click()
simulateArrival(Carousel.getInstance(carouselEl) as Carousel, item2)
nextBtn.click()
})
expect(item2.classList.contains('active')).toBe(true)
})
it('should navigate via data-tblr-slide="prev"', () => {
return new Promise<void>((resolve) => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div id="item1" class="carousel-item">item 1</div>',
' <div class="carousel-item active">item 2</div>',
' </div>',
' <button data-tblr-slide="prev" data-bs-target="#myCarousel">Prev</button>',
'</div>',
].join('')
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div id="item1" class="carousel-item">item 1</div>',
' <div class="carousel-item active">item 2</div>',
' </div>',
' <button data-tblr-slide="prev" data-bs-target="#myCarousel">Prev</button>',
'</div>',
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const prevBtn = fixtureEl.querySelector('[data-tblr-slide="prev"]') as HTMLElement
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const prevBtn = fixtureEl.querySelector('[data-tblr-slide="prev"]') as HTMLElement
const item1 = fixtureEl.querySelector('#item1')!
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item1')!.classList.contains('active')).toBe(true)
resolve()
})
prevBtn.click()
simulateArrival(Carousel.getInstance(carouselEl) as Carousel, item1)
prevBtn.click()
})
expect(item1.classList.contains('active')).toBe(true)
})
it('should handle data-tblr-slide-to in indicators', () => {
return new Promise<void>((resolve) => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-indicators">',
' <button class="active" data-tblr-slide-to="0" data-bs-target="#myCarousel" aria-current="true"></button>',
' <button data-tblr-slide-to="1" data-bs-target="#myCarousel"></button>',
' </div>',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' </div>',
'</div>',
].join('')
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-indicators">',
' <button class="active" data-tblr-slide-to="0" data-bs-target="#myCarousel" aria-current="true"></button>',
' <button data-tblr-slide-to="1" data-bs-target="#myCarousel"></button>',
' </div>',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' </div>',
'</div>',
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const trigger = fixtureEl.querySelectorAll('[data-tblr-slide-to]')[1] as HTMLElement
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const trigger = fixtureEl.querySelectorAll('[data-tblr-slide-to]')[1] as HTMLElement
const item2 = fixtureEl.querySelector('#item2')!
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item2')!.classList.contains('active')).toBe(true)
resolve()
})
trigger.click()
simulateArrival(Carousel.getInstance(carouselEl) as Carousel, item2)
trigger.click()
})
expect(item2.classList.contains('active')).toBe(true)
})
})
})
-1
View File
@@ -1336,7 +1336,6 @@ $carousel-caption-padding-y: 1.25rem !default;
$carousel-caption-spacer: 1.25rem !default;
$carousel-transition-duration: 0.6s !default;
$carousel-transition: transform $carousel-transition-duration ease-in-out !default;
$carousel-dark-indicator-active-bg: $black !default; // Deprecated in v5.3.4
$carousel-dark-caption-color: $black !default; // Deprecated in v5.3.4
+37 -54
View File
@@ -1,15 +1,9 @@
// Notes on the classes:
//
// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically)
// even when their scroll action started on a carousel, but for compatibility (with Firefox)
// we're preventing all actions instead
// 2. The .carousel-item-start and .carousel-item-end is used to indicate where
// the active slide is heading.
// 3. .active.carousel-item is the current slide.
// 4. .active.carousel-item-start and .active.carousel-item-end is the current
// slide in its in-transition state. Only one of these occurs at a time.
// 5. .carousel-item-next.carousel-item-start and .carousel-item-prev.carousel-item-end
// is the upcoming slide in transition.
// 1. .carousel-inner scrolls natively (CSS scroll-snap); the browser drives the slide
// transition, momentum and touch gestures instead of a JS transform/float engine.
// 2. .active.carousel-item is the current slide, kept in sync by an IntersectionObserver
// in carousel.ts — it no longer controls layout, only indicator/API state.
@use '../config' as *;
@@ -17,43 +11,31 @@
position: relative;
}
.carousel.pointer-event {
touch-action: pan-y;
}
.carousel-inner {
position: relative;
display: flex;
width: 100%;
overflow: hidden;
@include clearfix();
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
}
}
.carousel-item {
position: relative;
display: none;
float: inline-start;
width: 100%;
margin-inline-end: -100%;
flex: 0 0 100%;
scroll-snap-align: start;
scroll-snap-stop: always;
backface-visibility: hidden;
@include transition($carousel-transition);
}
.carousel-item.active,
.carousel-item-next,
.carousel-item-prev {
display: block;
}
.carousel-item-next:not(.carousel-item-start),
.active.carousel-item-end {
/* rtl:ignore */
transform: translateX(calc(var(--dir) * 100%));
}
.carousel-item-prev:not(.carousel-item-end),
.active.carousel-item-start {
/* rtl:ignore */
transform: translateX(calc(var(--dir) * -100%));
}
//
@@ -61,24 +43,25 @@
//
.carousel-fade {
.carousel-inner {
display: grid;
overflow: hidden;
scroll-snap-type: none;
}
.carousel-item {
opacity: 0;
transition-property: opacity;
transform: none;
}
.carousel-item.active,
.carousel-item-next.carousel-item-start,
.carousel-item-prev.carousel-item-end {
z-index: 1;
opacity: 1;
}
.active.carousel-item-start,
.active.carousel-item-end {
grid-area: 1 / 1;
z-index: 0;
visibility: hidden;
opacity: 0;
@include transition(opacity 0s $carousel-transition-duration);
@include transition(opacity $carousel-transition-duration ease-in-out, visibility 0s $carousel-transition-duration);
}
.carousel-item.active {
z-index: 1;
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
}