Skip to content

Commit

Permalink
fix(carousel): animate items with the same direction (#9325)
Browse files Browse the repository at this point in the history
**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.
  • Loading branch information
jcfranco committed May 14, 2024
1 parent 3819688 commit 6bf7b74
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 3 deletions.
Expand Up @@ -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`<calcite-carousel label="carousel">
<calcite-carousel-item label="item 1" selected><p>first</p></calcite-carousel-item>
<calcite-carousel-item label="item 2"><p>second</p></calcite-carousel-item>
<calcite-carousel-item label="item 3"><p>third</p></calcite-carousel-item>
</calcite-carousel>`,
);

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);
});
});
36 changes: 33 additions & 3 deletions packages/calcite-components/src/components/carousel/carousel.tsx
Expand Up @@ -16,6 +16,7 @@ import {
getElementDir,
slotChangeGetAssignedElements,
toAriaBoolean,
whenAnimationDone,
} from "../../utils/dom";
import { connectLocalized, disconnectLocalized, LocalizedComponent } from "../../utils/locale";
import { guid } from "../../utils/guid";
Expand Down Expand Up @@ -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<void> {
if (direction === "standby") {
return;
}

await whenAnimationDone(
this.itemContainer,
direction === "forward" ? "item-forward" : "item-backward",
);
this.direction = "standby";
}

@State() defaultMessages: CarouselMessages;

Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -528,6 +550,12 @@ export class Carousel
this.container = el;
};

private itemContainer: HTMLDivElement;

private storeItemContainerRef = (el: HTMLDivElement): void => {
this.itemContainer = el;
};

// --------------------------------------------------------------------------
//
// Render Methods
Expand Down Expand Up @@ -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}
>
<slot onSlotchange={this.handleSlotChange} />
</section>
Expand Down
58 changes: 58 additions & 0 deletions packages/calcite-components/src/utils/dom.ts
Expand Up @@ -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<void> {
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<void>((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();
}
}
});
}

0 comments on commit 6bf7b74

Please sign in to comment.