diff --git a/packages/calcite-components/src/components/carousel/carousel.e2e.ts b/packages/calcite-components/src/components/carousel/carousel.e2e.ts
index 33c767870fc..02313e8ea9e 100644
--- a/packages/calcite-components/src/components/carousel/carousel.e2e.ts
+++ b/packages/calcite-components/src/components/carousel/carousel.e2e.ts
@@ -930,4 +930,55 @@ describe("calcite-carousel", () => {
expect(selectedItem.id).toEqual("two");
});
});
+
+ it("item slide animation finishes between paging/selection", async () => {
+ const page = await newE2EPage();
+ await page.setContent(
+ html`
+ first
+ second
+ third
+ `,
+ );
+
+ const container = await page.find(`calcite-carousel >>> .${CSS.container}`);
+ const animationStartSpy = await container.spyOnEvent("animationstart");
+ const animationEndSpy = await container.spyOnEvent("animationend");
+ const nextButton = await page.find(`calcite-carousel >>> .${CSS.pageNext}`);
+
+ await nextButton.click();
+ await page.waitForChanges();
+ await nextButton.click();
+ await page.waitForChanges();
+
+ expect(animationStartSpy).toHaveReceivedEventTimes(2);
+ expect(animationEndSpy).toHaveReceivedEventTimes(2);
+
+ const previousButton = await page.find(`calcite-carousel >>> .${CSS.pagePrevious}`);
+ await previousButton.click();
+ await page.waitForChanges();
+ await previousButton.click();
+ await page.waitForChanges();
+
+ expect(animationStartSpy).toHaveReceivedEventTimes(4);
+ expect(animationEndSpy).toHaveReceivedEventTimes(4);
+
+ const [item1, item2, item3] = await page.findAll(`calcite-carousel >>> .${CSS.paginationItemIndividual}`);
+
+ await item2.click();
+ await page.waitForChanges();
+ await item3.click();
+ await page.waitForChanges();
+
+ expect(animationStartSpy).toHaveReceivedEventTimes(6);
+ expect(animationEndSpy).toHaveReceivedEventTimes(6);
+
+ await item2.click();
+ await page.waitForChanges();
+ await item1.click();
+ await page.waitForChanges();
+
+ expect(animationStartSpy).toHaveReceivedEventTimes(8);
+ expect(animationEndSpy).toHaveReceivedEventTimes(8);
+ });
});
diff --git a/packages/calcite-components/src/components/carousel/carousel.tsx b/packages/calcite-components/src/components/carousel/carousel.tsx
index c1e42482b57..dd1fb9edadf 100644
--- a/packages/calcite-components/src/components/carousel/carousel.tsx
+++ b/packages/calcite-components/src/components/carousel/carousel.tsx
@@ -16,6 +16,7 @@ import {
getElementDir,
slotChangeGetAssignedElements,
toAriaBoolean,
+ whenAnimationDone,
} from "../../utils/dom";
import { connectLocalized, disconnectLocalized, LocalizedComponent } from "../../utils/locale";
import { guid } from "../../utils/guid";
@@ -215,7 +216,20 @@ export class Carousel
@State() items: HTMLCalciteCarouselItemElement[] = [];
- @State() direction: "forward" | "backward";
+ @State() direction: "forward" | "backward" | "standby" = "standby";
+
+ @Watch("direction")
+ async directionWatcher(direction: string): Promise {
+ if (direction === "standby") {
+ return;
+ }
+
+ await whenAnimationDone(
+ this.itemContainer,
+ direction === "forward" ? "item-forward" : "item-backward",
+ );
+ this.direction = "standby";
+ }
@State() defaultMessages: CarouselMessages;
@@ -394,18 +408,26 @@ export class Carousel
private handleArrowClick = (event: MouseEvent): void => {
const direction = (event.target as HTMLDivElement).dataset.direction;
if (direction === "next") {
+ this.direction = "forward";
this.nextItem(true);
} else if (direction === "previous") {
+ this.direction = "backward";
this.previousItem();
}
};
private handleItemSelection = (event: MouseEvent): void => {
+ const item = event.target as HTMLCalciteActionElement;
+ const requestedPosition = parseInt(item.dataset.index);
+
+ if (requestedPosition === this.selectedIndex) {
+ return;
+ }
+
if (this.playing) {
this.handlePause(true);
}
- const item = event.target as HTMLCalciteActionElement;
- const requestedPosition = parseInt(item.dataset.index);
+
this.direction = requestedPosition > this.selectedIndex ? "forward" : "backward";
this.setSelectedItem(requestedPosition, true);
};
@@ -528,6 +550,12 @@ export class Carousel
this.container = el;
};
+ private itemContainer: HTMLDivElement;
+
+ private storeItemContainerRef = (el: HTMLDivElement): void => {
+ this.itemContainer = el;
+ };
+
// --------------------------------------------------------------------------
//
// Render Methods
@@ -653,6 +681,8 @@ export class Carousel
[CSS.itemContainerBackward]: direction === "backward",
}}
id={this.containerId}
+ // eslint-disable-next-line react/jsx-sort-props -- auto-generated by @esri/calcite-components/enforce-ref-last-prop
+ ref={this.storeItemContainerRef}
>
diff --git a/packages/calcite-components/src/utils/dom.ts b/packages/calcite-components/src/utils/dom.ts
index ae1de19b596..32bc69c429e 100644
--- a/packages/calcite-components/src/utils/dom.ts
+++ b/packages/calcite-components/src/utils/dom.ts
@@ -653,3 +653,61 @@ export function isBefore(a: HTMLElement, b: HTMLElement): boolean {
const children = Array.from(a.parentNode.children);
return children.indexOf(a) < children.indexOf(b);
}
+
+/**
+ * This util helps determine when an animation has completed.
+ *
+ * @param targetEl The element to watch for the animation to complete.
+ * @param animationName The name of the animation to watch for completion.
+ */
+export async function whenAnimationDone(targetEl: HTMLElement, animationName: string): Promise {
+ const { animationDuration: allDurations, animationName: allNames } = getComputedStyle(targetEl);
+
+ const allDurationsArray = allDurations.split(",");
+ const allPropsArray = allNames.split(",");
+ const propIndex = allPropsArray.indexOf(animationName);
+ const duration =
+ allDurationsArray[propIndex] ??
+ /* Safari will have a single duration value for the shorthand prop when multiple, separate names/props are defined,
+ so we fall back to it if there's no matching prop duration */
+ allDurationsArray[0];
+
+ if (duration === "0s") {
+ return Promise.resolve();
+ }
+
+ const startEvent = "animationstart";
+ const endEvent = "animationend";
+ const cancelEvent = "animationcancel";
+
+ return new Promise((resolve) => {
+ const fallbackTimeoutId = setTimeout(
+ (): void => {
+ targetEl.removeEventListener(startEvent, onStart);
+ targetEl.removeEventListener(endEvent, onEndOrCancel);
+ targetEl.removeEventListener(cancelEvent, onEndOrCancel);
+ resolve();
+ },
+ parseFloat(duration) * 1000,
+ );
+
+ targetEl.addEventListener(startEvent, onStart);
+ targetEl.addEventListener(endEvent, onEndOrCancel);
+ targetEl.addEventListener(cancelEvent, onEndOrCancel);
+
+ function onStart(event: AnimationEvent): void {
+ if (event.animationName === animationName && event.target === targetEl) {
+ clearTimeout(fallbackTimeoutId);
+ targetEl.removeEventListener(startEvent, onStart);
+ }
+ }
+
+ function onEndOrCancel(event: AnimationEvent): void {
+ if (event.animationName === animationName && event.target === targetEl) {
+ targetEl.removeEventListener(endEvent, onEndOrCancel);
+ targetEl.removeEventListener(cancelEvent, onEndOrCancel);
+ resolve();
+ }
+ }
+ });
+}