diff --git a/.changeset/carousel-scroll-snap.md b/.changeset/carousel-scroll-snap.md new file mode 100644 index 000000000..aa28363a4 --- /dev/null +++ b/.changeset/carousel-scroll-snap.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": minor +--- + +Rewrote Carousel on CSS scroll-snap and IntersectionObserver, dropping the transform-based slide engine. diff --git a/core/js/src/bootstrap/carousel.ts b/core/js/src/bootstrap/carousel.ts index e6277eb7b..5a3323d2a 100644 --- a/core/js/src/bootstrap/carousel.ts +++ b/core/js/src/bootstrap/carousel.ts @@ -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 | null _activeElement: HTMLElement | null _isSliding: boolean - touchTimeout: ReturnType | 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(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 { diff --git a/core/js/tests/unit/carousel.spec.ts b/core/js/tests/unit/carousel.spec.ts index 5c4a55397..dc9013e21 100644 --- a/core/js/tests/unit/carousel.spec.ts +++ b/core/js/tests/unit/carousel.spec.ts @@ -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 = [] + + 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((resolve) => { - fixtureEl.innerHTML = [''].join('') + fixtureEl.innerHTML = [''].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((resolve) => { - fixtureEl.innerHTML = [''].join('') + fixtureEl.innerHTML = [''].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((resolve) => { - fixtureEl.innerHTML = [''].join('') + fixtureEl.innerHTML = [''].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((resolve) => { - fixtureEl.innerHTML = [''].join('') + fixtureEl.innerHTML = [''].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((resolve) => { - fixtureEl.innerHTML = [''].join('') + fixtureEl.innerHTML = [''].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((resolve) => { - fixtureEl.innerHTML = [ - '', - ].join('') + fixtureEl.innerHTML = [ + '', + ].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((resolve) => { - fixtureEl.innerHTML = [ - '', - ].join('') + fixtureEl.innerHTML = [ + '', + ].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((resolve) => { - fixtureEl.innerHTML = [ - '', - ].join('') + fixtureEl.innerHTML = [ + '', + ].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) }) }) }) diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 939dbeb7c..4b37596aa 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -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 diff --git a/core/scss/bootstrap/_carousel.scss b/core/scss/bootstrap/_carousel.scss index c3f610d4c..c64c39a38 100644 --- a/core/scss/bootstrap/_carousel.scss +++ b/core/scss/bootstrap/_carousel.scss @@ -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; } }