): void {
super.updated(changed);
if (changed.has('interval')) {
this._stopCycle();
this._startCycle();
}
}
protected override hostClasses(): string {
const parts = ['carousel', 'slide'];
if (this.fade) parts.push('carousel-fade');
if (this.dark) parts.push('carousel-dark');
return parts.join(' ');
}
/** Show the next slide. */
next(): void {
this._slideTo(this._activeIndex + 1, 'next');
}
/** Show the previous slide. */
prev(): void {
this._slideTo(this._activeIndex - 1, 'prev');
}
/**
* Show a specific slide by index. The `direction` is inferred from index
* comparison unless the carousel is animating, in which case it's queued
* after the current animation.
*/
to(index: number): void {
const target = ((index % this._items().length) + this._items().length) % this._items().length;
if (target === this._activeIndex) return;
const direction = target > this._activeIndex ? 'next' : 'prev';
this._slideTo(target, direction);
}
/** Pause auto-cycling. */
pause(): void {
this._stopCycle();
}
/** Resume auto-cycling using the configured `interval`. */
cycle(): void {
this._startCycle();
}
private _initActiveFromDom(): void {
const items = this._items();
if (!items.length) return;
const active = items.findIndex((item) => item.active);
this._activeIndex = active === -1 ? 0 : active;
items.forEach((item, i) => {
item.active = i === this._activeIndex;
item.transitionState = '';
});
}
private _slideTo(targetRaw: number, direction: 'next' | 'prev'): void {
if (this._animating) return;
const items = this._items();
if (items.length < 2) return;
let target = targetRaw;
if (this.wrap) {
target = ((target % items.length) + items.length) % items.length;
} else {
if (target < 0 || target >= items.length) return;
}
if (target === this._activeIndex) return;
const from = this._activeIndex;
const fromItem = items[from];
const toItem = items[target];
if (!fromItem || !toItem) return;
const slideEvent = new CustomEvent('bs-slide', {
bubbles: true,
composed: true,
cancelable: true,
detail: { from, to: target, direction },
});
if (!this.dispatchEvent(slideEvent)) return;
this._animating = true;
// Phase 1: place the incoming slide off-screen.
toItem.transitionState = direction === 'next' ? 'next' : 'prev';
// Force reflow so the transform is registered before we add `start`/`end`.
void toItem.offsetHeight;
// Phase 2: trigger the transition by adding the matching start/end class.
requestAnimationFrame(() => {
toItem.transitionState = direction === 'next' ? 'next-start' : 'prev-end';
fromItem.transitionState = direction === 'next' ? 'start' : 'end';
});
const TRANSITION_MS = this.fade ? 600 : 600;
window.setTimeout(() => {
fromItem.active = false;
fromItem.transitionState = '';
toItem.active = true;
toItem.transitionState = '';
this._activeIndex = target;
this._animating = false;
this.dispatchEvent(
new CustomEvent('bs-slid', {
bubbles: true,
composed: true,
detail: { from, to: target, direction },
}),
);
}, TRANSITION_MS);
}
private _startCycle(): void {
if (!this.interval || this.interval <= 0) return;
if (this._isHovered && this.pauseOnHover) return;
if (this._intervalId !== null) return;
this._intervalId = window.setInterval(() => {
if (this._isHovered && this.pauseOnHover) return;
this.next();
}, this.interval);
}
private _stopCycle(): void {
if (this._intervalId === null) return;
clearInterval(this._intervalId);
this._intervalId = null;
}
private _onMouseEnter = () => {
this._isHovered = true;
if (this.pauseOnHover) this._stopCycle();
};
private _onMouseLeave = () => {
this._isHovered = false;
this._startCycle();
};
private _onKeydown = (ev: KeyboardEvent) => {
if (ev.key === 'ArrowLeft') {
ev.preventDefault();
this.prev();
} else if (ev.key === 'ArrowRight') {
ev.preventDefault();
this.next();
}
};
// ---- touch ---------------------------------------------------------------
private _touchStartX = 0;
private _touchEndX = 0;
private _onTouchStart = (ev: TouchEvent) => {
this._touchStartX = ev.changedTouches[0].screenX;
};
private _onTouchEnd = (ev: TouchEvent) => {
this._touchEndX = ev.changedTouches[0].screenX;
const delta = this._touchEndX - this._touchStartX;
if (Math.abs(delta) < 40) return;
if (delta < 0) this.next();
else this.prev();
};
private _wireTouch(): void {
this.addEventListener('touchstart', this._onTouchStart, { passive: true });
this.addEventListener('touchend', this._onTouchEnd, { passive: true });
}
private _unwireTouch(): void {
this.removeEventListener('touchstart', this._onTouchStart);
this.removeEventListener('touchend', this._onTouchEnd);
}
// ---- render --------------------------------------------------------------
private _onIndicatorClick(i: number): void {
if (i === this._activeIndex) return;
const direction = i > this._activeIndex ? 'next' : 'prev';
this._slideTo(i, direction);
}
override render() {
// Render indicator buttons based on the current item count. Re-evaluated
// on every update — Lit diffing handles incremental changes.
const items = this._items();
const count = items.length;
return html`
${this.indicators && !this.noIndicators && count > 0
? html`
${items.map(
(_, i) => html``,
)}
`
: nothing}
${this.controls && !this.noControls && count > 1
? html`
`
: nothing}
`;
}
}
defineElement('bs-carousel', BsCarousel);
// Re-export the item for ergonomic single-import usage.
export { BsCarouselItem } from './carousel-item.js';
import './carousel-item.js';
declare global {
interface HTMLElementTagNameMap {
'bs-carousel': BsCarousel;
}
}