Skip to content

Commit 6bf7b74

Browse files
authored
fix(carousel): animate items with the same direction (#9325)
**Related Issue:** #9232 ## Summary Updates carousel to wait for item animations before each slide. **Note**: I'll submit a follow-up PR to refactor [`openCloseComponent.onToggleOpenCloseComponent`](https://github.com/Esri/calcite-design-system/blob/main/packages/calcite-components/src/utils/openCloseComponent.ts#L91) to use the same DOM util as carousel.
1 parent 3819688 commit 6bf7b74

File tree

3 files changed

+142
-3
lines changed

3 files changed

+142
-3
lines changed

packages/calcite-components/src/components/carousel/carousel.e2e.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,4 +930,55 @@ describe("calcite-carousel", () => {
930930
expect(selectedItem.id).toEqual("two");
931931
});
932932
});
933+
934+
it("item slide animation finishes between paging/selection", async () => {
935+
const page = await newE2EPage();
936+
await page.setContent(
937+
html`<calcite-carousel label="carousel">
938+
<calcite-carousel-item label="item 1" selected><p>first</p></calcite-carousel-item>
939+
<calcite-carousel-item label="item 2"><p>second</p></calcite-carousel-item>
940+
<calcite-carousel-item label="item 3"><p>third</p></calcite-carousel-item>
941+
</calcite-carousel>`,
942+
);
943+
944+
const container = await page.find(`calcite-carousel >>> .${CSS.container}`);
945+
const animationStartSpy = await container.spyOnEvent("animationstart");
946+
const animationEndSpy = await container.spyOnEvent("animationend");
947+
const nextButton = await page.find(`calcite-carousel >>> .${CSS.pageNext}`);
948+
949+
await nextButton.click();
950+
await page.waitForChanges();
951+
await nextButton.click();
952+
await page.waitForChanges();
953+
954+
expect(animationStartSpy).toHaveReceivedEventTimes(2);
955+
expect(animationEndSpy).toHaveReceivedEventTimes(2);
956+
957+
const previousButton = await page.find(`calcite-carousel >>> .${CSS.pagePrevious}`);
958+
await previousButton.click();
959+
await page.waitForChanges();
960+
await previousButton.click();
961+
await page.waitForChanges();
962+
963+
expect(animationStartSpy).toHaveReceivedEventTimes(4);
964+
expect(animationEndSpy).toHaveReceivedEventTimes(4);
965+
966+
const [item1, item2, item3] = await page.findAll(`calcite-carousel >>> .${CSS.paginationItemIndividual}`);
967+
968+
await item2.click();
969+
await page.waitForChanges();
970+
await item3.click();
971+
await page.waitForChanges();
972+
973+
expect(animationStartSpy).toHaveReceivedEventTimes(6);
974+
expect(animationEndSpy).toHaveReceivedEventTimes(6);
975+
976+
await item2.click();
977+
await page.waitForChanges();
978+
await item1.click();
979+
await page.waitForChanges();
980+
981+
expect(animationStartSpy).toHaveReceivedEventTimes(8);
982+
expect(animationEndSpy).toHaveReceivedEventTimes(8);
983+
});
933984
});

packages/calcite-components/src/components/carousel/carousel.tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
getElementDir,
1717
slotChangeGetAssignedElements,
1818
toAriaBoolean,
19+
whenAnimationDone,
1920
} from "../../utils/dom";
2021
import { connectLocalized, disconnectLocalized, LocalizedComponent } from "../../utils/locale";
2122
import { guid } from "../../utils/guid";
@@ -215,7 +216,20 @@ export class Carousel
215216

216217
@State() items: HTMLCalciteCarouselItemElement[] = [];
217218

218-
@State() direction: "forward" | "backward";
219+
@State() direction: "forward" | "backward" | "standby" = "standby";
220+
221+
@Watch("direction")
222+
async directionWatcher(direction: string): Promise<void> {
223+
if (direction === "standby") {
224+
return;
225+
}
226+
227+
await whenAnimationDone(
228+
this.itemContainer,
229+
direction === "forward" ? "item-forward" : "item-backward",
230+
);
231+
this.direction = "standby";
232+
}
219233

220234
@State() defaultMessages: CarouselMessages;
221235

@@ -394,18 +408,26 @@ export class Carousel
394408
private handleArrowClick = (event: MouseEvent): void => {
395409
const direction = (event.target as HTMLDivElement).dataset.direction;
396410
if (direction === "next") {
411+
this.direction = "forward";
397412
this.nextItem(true);
398413
} else if (direction === "previous") {
414+
this.direction = "backward";
399415
this.previousItem();
400416
}
401417
};
402418

403419
private handleItemSelection = (event: MouseEvent): void => {
420+
const item = event.target as HTMLCalciteActionElement;
421+
const requestedPosition = parseInt(item.dataset.index);
422+
423+
if (requestedPosition === this.selectedIndex) {
424+
return;
425+
}
426+
404427
if (this.playing) {
405428
this.handlePause(true);
406429
}
407-
const item = event.target as HTMLCalciteActionElement;
408-
const requestedPosition = parseInt(item.dataset.index);
430+
409431
this.direction = requestedPosition > this.selectedIndex ? "forward" : "backward";
410432
this.setSelectedItem(requestedPosition, true);
411433
};
@@ -528,6 +550,12 @@ export class Carousel
528550
this.container = el;
529551
};
530552

553+
private itemContainer: HTMLDivElement;
554+
555+
private storeItemContainerRef = (el: HTMLDivElement): void => {
556+
this.itemContainer = el;
557+
};
558+
531559
// --------------------------------------------------------------------------
532560
//
533561
// Render Methods
@@ -653,6 +681,8 @@ export class Carousel
653681
[CSS.itemContainerBackward]: direction === "backward",
654682
}}
655683
id={this.containerId}
684+
// eslint-disable-next-line react/jsx-sort-props -- auto-generated by @esri/calcite-components/enforce-ref-last-prop
685+
ref={this.storeItemContainerRef}
656686
>
657687
<slot onSlotchange={this.handleSlotChange} />
658688
</section>

packages/calcite-components/src/utils/dom.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,3 +653,61 @@ export function isBefore(a: HTMLElement, b: HTMLElement): boolean {
653653
const children = Array.from(a.parentNode.children);
654654
return children.indexOf(a) < children.indexOf(b);
655655
}
656+
657+
/**
658+
* This util helps determine when an animation has completed.
659+
*
660+
* @param targetEl The element to watch for the animation to complete.
661+
* @param animationName The name of the animation to watch for completion.
662+
*/
663+
export async function whenAnimationDone(targetEl: HTMLElement, animationName: string): Promise<void> {
664+
const { animationDuration: allDurations, animationName: allNames } = getComputedStyle(targetEl);
665+
666+
const allDurationsArray = allDurations.split(",");
667+
const allPropsArray = allNames.split(",");
668+
const propIndex = allPropsArray.indexOf(animationName);
669+
const duration =
670+
allDurationsArray[propIndex] ??
671+
/* Safari will have a single duration value for the shorthand prop when multiple, separate names/props are defined,
672+
so we fall back to it if there's no matching prop duration */
673+
allDurationsArray[0];
674+
675+
if (duration === "0s") {
676+
return Promise.resolve();
677+
}
678+
679+
const startEvent = "animationstart";
680+
const endEvent = "animationend";
681+
const cancelEvent = "animationcancel";
682+
683+
return new Promise<void>((resolve) => {
684+
const fallbackTimeoutId = setTimeout(
685+
(): void => {
686+
targetEl.removeEventListener(startEvent, onStart);
687+
targetEl.removeEventListener(endEvent, onEndOrCancel);
688+
targetEl.removeEventListener(cancelEvent, onEndOrCancel);
689+
resolve();
690+
},
691+
parseFloat(duration) * 1000,
692+
);
693+
694+
targetEl.addEventListener(startEvent, onStart);
695+
targetEl.addEventListener(endEvent, onEndOrCancel);
696+
targetEl.addEventListener(cancelEvent, onEndOrCancel);
697+
698+
function onStart(event: AnimationEvent): void {
699+
if (event.animationName === animationName && event.target === targetEl) {
700+
clearTimeout(fallbackTimeoutId);
701+
targetEl.removeEventListener(startEvent, onStart);
702+
}
703+
}
704+
705+
function onEndOrCancel(event: AnimationEvent): void {
706+
if (event.animationName === animationName && event.target === targetEl) {
707+
targetEl.removeEventListener(endEvent, onEndOrCancel);
708+
targetEl.removeEventListener(cancelEvent, onEndOrCancel);
709+
resolve();
710+
}
711+
}
712+
});
713+
}

0 commit comments

Comments
 (0)