Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flyout-frozen-exit-animation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"reshaped": minor
---

Restored the `Flyout` hide animation by default for `Select`, `DropdownMenu` and `Autocomplete`. Flyout content is now frozen while animating out, so it keeps its last rendered state even when the component updates on close, for example when `Select` changes its selected value. `disableHideAnimation` is still supported to opt out of the animation. Content that's animating out no longer reacts to pointer events, content closed right after opening no longer stays mounted, and focus now returns to the trigger when the content is closed without an animation, for example with reduced motion enabled.
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,6 @@ const Autocomplete: React.FC<T.Props> = (props) => {
fallbackMinHeight={fallbackMinHeight}
contentMaxHeight={contentMaxHeight}
contentZIndex={contentZIndex}
disableHideAnimation
instanceRef={instanceRef}
>
<DropdownMenu.Trigger>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ const DropdownMenu: React.FC<T.Props> = (props) => {
padding={1}
trapFocusMode={trapFocusMode}
triggerType={triggerType}
disableHideAnimation={triggerType !== "hover"}
>
{children}
</Popover>
Expand Down
1 change: 1 addition & 0 deletions packages/reshaped/src/components/Flyout/Flyout.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
.positioner.--visible .content {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: all;
}

.positioner.--animated .content {
Expand Down
1 change: 1 addition & 0 deletions packages/reshaped/src/components/Flyout/Flyout.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ export type ContentProps = {

export type ContextProps = {
id: string;
active: boolean;
flyout: UseFlyoutData;
width?: Width;
triggerElRef?: React.RefObject<HTMLButtonElement | null>;
Expand Down
4 changes: 3 additions & 1 deletion packages/reshaped/src/components/Flyout/FlyoutContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React from "react";
import { classNames } from "@reshaped/utilities";

import Freeze from "@/components/_private/Freeze";
import Portal from "@/components/_private/Portal";
import useIsomorphicLayoutEffect from "@/hooks/useIsomorphicLayoutEffect";
import ScrollArea from "../ScrollArea";
Expand All @@ -14,6 +15,7 @@ import s from "./Flyout.module.css";
const FlyoutContent: React.FC<T.ContentProps> = (props) => {
const { children, className, attributes, colorMode } = props;
const {
active,
flyout,
id,
triggerElRef,
Expand Down Expand Up @@ -119,7 +121,7 @@ const FlyoutContent: React.FC<T.ContentProps> = (props) => {
scrollableAttributes={scrollableAttributes}
scrollableClassName={scrollableClassName}
>
{children}
<Freeze frozen={!active}>{children}</Freeze>
</ScrollArea>
</div>
</div>
Expand Down
19 changes: 16 additions & 3 deletions packages/reshaped/src/components/Flyout/FlyoutControlled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,18 @@ const FlyoutControlled: React.FC<T.ControlledProps & T.DefaultProps> = (props) =
if (status === "rendered") show();
}, [status, show]);

/**
* Closing before the open transition starts cancels it instead of reversing,
* so there is no transitionend to wait for
*/
useIsomorphicLayoutEffect(() => {
if (status !== "hidden") return;
if (flyoutElRef.current?.firstElementChild?.getAnimations?.().length) return;

remove();
onAfterCloseRef.current?.();
}, [status, remove, onAfterCloseRef]);

/**
* Handle focus trap
*
Expand Down Expand Up @@ -402,8 +414,8 @@ const FlyoutControlled: React.FC<T.ControlledProps & T.DefaultProps> = (props) =
}, [status, triggerType, trapFocusMode, autoFocus]);

React.useEffect(() => {
if (!disableHideAnimation && status !== "hidden") return;
if (disableHideAnimation && isRendered) return;
// Release when the hide animation starts or right away when the content is removed without it
if (status !== "hidden" && isRendered) return;

if (trapFocusRef.current?.trapped) {
/* Locking the popover to not open it again on trigger focus */
Expand All @@ -418,7 +430,7 @@ const FlyoutControlled: React.FC<T.ControlledProps & T.DefaultProps> = (props) =
trapFocusRef.current.release({ withoutFocusReturn: !shouldReturnFocusRef.current });
shouldReturnFocusRef.current = true;
}
}, [status, isRendered, triggerType, disableHideAnimation]);
}, [status, isRendered, triggerType]);

/**
* Clean up safe polygon tracking on unmount or when flyout closes
Expand Down Expand Up @@ -477,6 +489,7 @@ const FlyoutControlled: React.FC<T.ControlledProps & T.DefaultProps> = (props) =
<Provider
value={{
id,
active: !!active,
flyout,
width,
triggerElRef,
Expand Down
180 changes: 180 additions & 0 deletions packages/reshaped/src/components/Flyout/tests/Flyout.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,186 @@ export const disableHideAnimation = {
render: () => <Demo disableHideAnimation defaultActive />,
};

export const testFrozenContentOnClose: StoryObj<{ handleAfterClose: ReturnType<typeof fn> }> = {
name: "test: content doesn't change while closing",
args: {
handleAfterClose: fn(),
},
render: (args) => {
const [active, setActive] = React.useState(false);
const [value, setValue] = React.useState("First");

return (
<Flyout
active={active}
onOpen={() => setActive(true)}
onClose={() => setActive(false)}
onAfterClose={args.handleAfterClose}
>
<Flyout.Trigger>
{(attributes) => <Button attributes={attributes}>{value}</Button>}
</Flyout.Trigger>
<Flyout.Content>
<Content>
<div data-testid="selected-value">Selected: {value}</div>
<Button
onClick={() => {
setValue("Second");
setActive(false);
}}
>
Select second
</Button>
</Content>
</Flyout.Content>
</Flyout>
);
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement.ownerDocument.body);

// Transitions are disabled for the first frames after the color mode gets applied
await waitFor(() => {
expect(document.documentElement).not.toHaveAttribute("data-rs-no-transition");
});

await userEvent.click(canvas.getByRole("button", { name: "First" }));

await waitFor(() => {
expect(canvas.getByTestId("selected-value")).toBeVisible();
});

// Record the content state after every DOM update since the hide animation might end before click resolves
const snapshots: { text?: string | null; visible: boolean }[] = [];
const observer = new MutationObserver(() => {
const el = canvasElement.ownerDocument.querySelector<HTMLElement>(
"[data-testid=selected-value]"
);
if (!el) return;
snapshots.push({ text: el.textContent, visible: el.checkVisibility() });
});
observer.observe(canvasElement.ownerDocument.body, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});

await userEvent.click(canvas.getByText("Select second"));

await waitFor(() => {
expect(canvas.queryByTestId("selected-value")).not.toBeInTheDocument();
});
observer.disconnect();

// Trigger is updated right away while the content keeps its last state during the hide animation
expect(canvas.getAllByRole("button")[0]).toHaveTextContent("Second");
expect(snapshots.length).toBeGreaterThan(0);
snapshots.forEach((snapshot) => {
expect(snapshot).toEqual({ text: "Selected: First", visible: true });
});
expect(args.handleAfterClose).toHaveBeenCalledTimes(1);
},
};

export const testCloseRightAfterOpen: StoryObj<{ handleAfterClose: ReturnType<typeof fn> }> = {
name: "test: close right after opening",
args: {
handleAfterClose: fn(),
},
render: (args) => {
const [active, setActive] = React.useState(false);
const [closeOnOpen, setCloseOnOpen] = React.useState(false);

// Closing in the same frame, before the open transition starts
React.useEffect(() => {
if (active && closeOnOpen) setActive(false);
}, [active, closeOnOpen]);

return (
<Flyout active={active} onAfterClose={args.handleAfterClose}>
<Flyout.Trigger>
{(attributes) => (
<Button
attributes={attributes}
onClick={() => {
setCloseOnOpen(true);
setActive(true);
}}
>
Trigger
</Button>
)}
</Flyout.Trigger>
<Flyout.Content>
<Content />
</Flyout.Content>
</Flyout>
);
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement.ownerDocument.body);

// Transitions are disabled for the first frames after the color mode gets applied
await waitFor(() => {
expect(document.documentElement).not.toHaveAttribute("data-rs-no-transition");
});

await userEvent.click(canvas.getByRole("button", { name: "Trigger" }));

await waitFor(() => {
expect(canvas.queryByText("Content")).not.toBeInTheDocument();
expect(args.handleAfterClose).toHaveBeenCalledTimes(1);
});
},
};

export const testFocusReturnWithoutTransitions: StoryObj = {
name: "test: focus returns to the trigger with transitions disabled",
render: () => (
<Flyout>
<Flyout.Trigger>
{(attributes) => <Button attributes={attributes}>Trigger</Button>}
</Flyout.Trigger>
<Flyout.Content>
<Content>
<Button onClick={() => {}}>Inside</Button>
</Content>
</Flyout.Content>
</Flyout>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement.ownerDocument.body);
const trigger = canvas.getByRole("button", { name: "Trigger" });

// Wait for the provider to enable transitions after mount before disabling them again
await waitFor(() => {
expect(document.documentElement).not.toHaveAttribute("data-rs-no-transition");
});

// Same as prefers-reduced-motion, content is removed without the hide animation
document.documentElement.setAttribute("data-rs-no-transition", "true");

try {
trigger.focus();
await userEvent.keyboard("{Enter}");

await waitFor(() => {
expect(canvas.getByRole("dialog")).toContainElement(document.activeElement as HTMLElement);
});

await userEvent.keyboard("{Escape}");

await waitFor(() => {
expect(canvas.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(trigger).toHaveFocus();
} finally {
document.documentElement.removeAttribute("data-rs-no-transition");
}
},
};

export const disabled: StoryObj<{ handleOpen: ReturnType<typeof fn> }> = {
name: "disabled",
args: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,6 @@ const SelectCustomControlled: React.FC<T.CustomControlledProps> = (props) => {
return (
<DropdownMenu
width={width}
disableHideAnimation
position={position ?? "bottom"}
fallbackPositions={fallbackPositions ?? (position ? undefined : ["bottom", "top"])}
fallbackAdjustLayout={!openedWithTouch}
Expand Down
18 changes: 16 additions & 2 deletions packages/reshaped/src/components/Select/tests/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { StoryObj } from "@storybook/react-vite";
import React from "react";
import { expect, fn, Mock, userEvent, within } from "storybook/test";
import { expect, fn, Mock, userEvent, waitFor, within } from "storybook/test";

import Actionable from "@/components/Actionable";
import Badge from "@/components/Badge";
Expand Down Expand Up @@ -83,6 +83,11 @@ export const base: StoryObj = {

await userEvent.click(document.body);

// Wait for the previous menu to finish its hide animation
await waitFor(() => {
expect(within(canvasElement.ownerDocument.body).queryAllByRole("option")).toHaveLength(0);
});

const [_, triggerWithGroups] = canvas.getAllByRole("button");
const hiddenInputs2 = canvasElement.querySelectorAll('input[type="hidden"]');
const [__, hiddenInputWithGroups] = Array.from(hiddenInputs2);
Expand Down Expand Up @@ -435,7 +440,11 @@ export const renderTrigger: StoryObj<{ handleChange: Mock }> = {

expect(hiddenInput).toHaveValue("turtle");
expect(trigger).toHaveTextContent("Turtle");
expect(trigger).toHaveAttribute("aria-expanded", "false");

// Trigger stays expanded until the hide animation ends
await waitFor(() => {
expect(trigger).toHaveAttribute("aria-expanded", "false");
});
expect(args.handleChange).toHaveBeenCalledTimes(1);
expect(args.handleChange).toHaveBeenCalledWith({
name: "animal",
Expand Down Expand Up @@ -843,6 +852,11 @@ export const defaultHandlers: StoryObj<{
// Controlled
await userEvent.click(document.body);

// Wait for the previous menu to finish its hide animation
await waitFor(() => {
expect(within(canvasElement.ownerDocument.body).queryAllByRole("option")).toHaveLength(0);
});

const [__, controlled, focusable] = canvas.getAllByRole("button");
const hiddenInputs2 = canvasElement.querySelectorAll('input[type="hidden"]');
const [___, inputControlled] = Array.from(hiddenInputs2);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.root {
display: contents;
}
Loading
Loading