mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Rebuild Carousel on CSS scroll-snap and IntersectionObserver (#3057)
This commit is contained in:
1 parent
7f98716422
commit
9052cbfbd4
5 files changed
+289
-271
No files matched your search
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user