diff --git a/.changeset/flyout-frozen-exit-animation.md b/.changeset/flyout-frozen-exit-animation.md new file mode 100644 index 00000000..91f98c8d --- /dev/null +++ b/.changeset/flyout-frozen-exit-animation.md @@ -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. diff --git a/packages/reshaped/src/components/Autocomplete/Autocomplete.tsx b/packages/reshaped/src/components/Autocomplete/Autocomplete.tsx index 740e2950..d2f7cf9c 100644 --- a/packages/reshaped/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/reshaped/src/components/Autocomplete/Autocomplete.tsx @@ -207,7 +207,6 @@ const Autocomplete: React.FC = (props) => { fallbackMinHeight={fallbackMinHeight} contentMaxHeight={contentMaxHeight} contentZIndex={contentZIndex} - disableHideAnimation instanceRef={instanceRef} > diff --git a/packages/reshaped/src/components/DropdownMenu/DropdownMenu.tsx b/packages/reshaped/src/components/DropdownMenu/DropdownMenu.tsx index 601790b7..11e299d2 100644 --- a/packages/reshaped/src/components/DropdownMenu/DropdownMenu.tsx +++ b/packages/reshaped/src/components/DropdownMenu/DropdownMenu.tsx @@ -35,7 +35,6 @@ const DropdownMenu: React.FC = (props) => { padding={1} trapFocusMode={trapFocusMode} triggerType={triggerType} - disableHideAnimation={triggerType !== "hover"} > {children} diff --git a/packages/reshaped/src/components/Flyout/Flyout.module.css b/packages/reshaped/src/components/Flyout/Flyout.module.css index 48feb9f6..7dfbb368 100644 --- a/packages/reshaped/src/components/Flyout/Flyout.module.css +++ b/packages/reshaped/src/components/Flyout/Flyout.module.css @@ -101,6 +101,7 @@ .positioner.--visible .content { opacity: 1; transform: scale(1) translateY(0); + pointer-events: all; } .positioner.--animated .content { diff --git a/packages/reshaped/src/components/Flyout/Flyout.types.ts b/packages/reshaped/src/components/Flyout/Flyout.types.ts index c891c470..c635e64d 100644 --- a/packages/reshaped/src/components/Flyout/Flyout.types.ts +++ b/packages/reshaped/src/components/Flyout/Flyout.types.ts @@ -200,6 +200,7 @@ export type ContentProps = { export type ContextProps = { id: string; + active: boolean; flyout: UseFlyoutData; width?: Width; triggerElRef?: React.RefObject; diff --git a/packages/reshaped/src/components/Flyout/FlyoutContent.tsx b/packages/reshaped/src/components/Flyout/FlyoutContent.tsx index ac07fef6..6d35d0d6 100644 --- a/packages/reshaped/src/components/Flyout/FlyoutContent.tsx +++ b/packages/reshaped/src/components/Flyout/FlyoutContent.tsx @@ -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"; @@ -14,6 +15,7 @@ import s from "./Flyout.module.css"; const FlyoutContent: React.FC = (props) => { const { children, className, attributes, colorMode } = props; const { + active, flyout, id, triggerElRef, @@ -119,7 +121,7 @@ const FlyoutContent: React.FC = (props) => { scrollableAttributes={scrollableAttributes} scrollableClassName={scrollableClassName} > - {children} + {children} diff --git a/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx b/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx index e43c3e90..c1868c04 100644 --- a/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx +++ b/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx @@ -370,6 +370,18 @@ const FlyoutControlled: React.FC = (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 * @@ -402,8 +414,8 @@ const FlyoutControlled: React.FC = (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 */ @@ -418,7 +430,7 @@ const FlyoutControlled: React.FC = (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 @@ -477,6 +489,7 @@ const FlyoutControlled: React.FC = (props) = , }; +export const testFrozenContentOnClose: StoryObj<{ handleAfterClose: ReturnType }> = { + 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 ( + setActive(true)} + onClose={() => setActive(false)} + onAfterClose={args.handleAfterClose} + > + + {(attributes) => } + + + +
Selected: {value}
+ +
+
+
+ ); + }, + 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( + "[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 }> = { + 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 ( + + + {(attributes) => ( + + )} + + + + + + ); + }, + 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: () => ( + + + {(attributes) => } + + + + + + + + ), + 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 }> = { name: "disabled", args: { diff --git a/packages/reshaped/src/components/Select/SelectCustomControlled.tsx b/packages/reshaped/src/components/Select/SelectCustomControlled.tsx index bd8a7d5b..43d85075 100644 --- a/packages/reshaped/src/components/Select/SelectCustomControlled.tsx +++ b/packages/reshaped/src/components/Select/SelectCustomControlled.tsx @@ -179,7 +179,6 @@ const SelectCustomControlled: React.FC = (props) => { return ( { + 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); @@ -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", @@ -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); diff --git a/packages/reshaped/src/components/_private/Freeze/Freeze.module.css b/packages/reshaped/src/components/_private/Freeze/Freeze.module.css new file mode 100644 index 00000000..9efb21f7 --- /dev/null +++ b/packages/reshaped/src/components/_private/Freeze/Freeze.module.css @@ -0,0 +1,3 @@ +.root { + display: contents; +} diff --git a/packages/reshaped/src/components/_private/Freeze/Freeze.tsx b/packages/reshaped/src/components/_private/Freeze/Freeze.tsx new file mode 100644 index 00000000..583eebbf --- /dev/null +++ b/packages/reshaped/src/components/_private/Freeze/Freeze.tsx @@ -0,0 +1,67 @@ +"use client"; + +import React from "react"; + +import type * as T from "./Freeze.types"; +import s from "./Freeze.module.css"; + +/** + * Transitions never commit an already visible Suspense boundary switching to its fallback, + * so we stop freezing if the frozen render isn't committed in time instead of blocking them forever + */ +const COMMIT_TIMEOUT = 100; +const infinitePromise = new Promise(() => {}); + +const Suspend: React.FC<{ stateRef: React.RefObject }> = (props) => { + const state = props.stateRef.current; + + if (state.expired) return null; + if (state.committed) throw infinitePromise; + + state.pending ??= new Promise((resolve) => { + setTimeout(() => { + state.expired = !state.committed; + resolve(); + }, COMMIT_TIMEOUT); + }); + + throw state.pending; +}; + +const Freeze: React.FC = (props) => { + const { frozen, children } = props; + const stateRef = React.useRef({}); + const elRef = React.useRef(null); + + // Insertion effect is the earliest opportunity to undo the Suspense display: none + React.useInsertionEffect(() => { + if (!frozen) { + stateRef.current = {}; + return; + } + + if (stateRef.current.expired) return; + + stateRef.current.committed = true; + elRef.current?.style.removeProperty("display"); + }, [frozen]); + + return ( + + {/* React detaches refs in suspended trees, so we keep the last attached element */} +
{ + if (el) elRef.current = el; + }} + > + {frozen && } + {children} +
+
+ ); +}; + +Freeze.displayName = "Freeze"; + +export default Freeze; diff --git a/packages/reshaped/src/components/_private/Freeze/Freeze.types.ts b/packages/reshaped/src/components/_private/Freeze/Freeze.types.ts new file mode 100644 index 00000000..d66545a8 --- /dev/null +++ b/packages/reshaped/src/components/_private/Freeze/Freeze.types.ts @@ -0,0 +1,16 @@ +import type React from "react"; + +export type Props = { + /** Stop committing updates to the children and keep their last rendered output on the screen */ + frozen: boolean; + /** Node for inserting children */ + children?: React.ReactNode; +}; + +export type State = { + /** Frozen render was committed, children stay suspended until unfrozen */ + committed?: boolean; + /** Frozen render wasn't committed in time, children render without freezing */ + expired?: boolean; + pending?: Promise; +}; diff --git a/packages/reshaped/src/components/_private/Freeze/index.ts b/packages/reshaped/src/components/_private/Freeze/index.ts new file mode 100644 index 00000000..f8dd5620 --- /dev/null +++ b/packages/reshaped/src/components/_private/Freeze/index.ts @@ -0,0 +1 @@ +export { default } from "./Freeze"; diff --git a/packages/reshaped/src/components/_private/Freeze/tests/Freeze.stories.tsx b/packages/reshaped/src/components/_private/Freeze/tests/Freeze.stories.tsx new file mode 100644 index 00000000..38a9e34e --- /dev/null +++ b/packages/reshaped/src/components/_private/Freeze/tests/Freeze.stories.tsx @@ -0,0 +1,103 @@ +import { StoryObj } from "@storybook/react-vite"; +import React from "react"; +import { expect, userEvent, waitFor, within } from "storybook/test"; + +import Freeze from "@/components/_private/Freeze"; +import Button from "@/components/Button"; +import View from "@/components/View"; + +export default { title: "Internal/Freeze" }; + +const ValueContext = React.createContext(0); + +const ContextValue = () => { + const value = React.useContext(ValueContext); + + return
Context: {value}
; +}; + +const Demo = (props: { transition?: boolean }) => { + const [frozen, setFrozen] = React.useState(false); + const [value, setValue] = React.useState(0); + + const update = (nextFrozen: boolean) => { + const apply = () => { + setFrozen(nextFrozen); + setValue((prev) => prev + 1); + }; + + if (props.transition) { + React.startTransition(apply); + } else { + apply(); + } + }; + + return ( + + + + + + +
{frozen ? "Frozen" : "Active"}
+ + + +
Prop: {value}
+ +
+
+
+ ); +}; + +export const base: StoryObj = { + name: "base", + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const [freezeButton, unfreezeButton] = canvas.getAllByRole("button"); + + await userEvent.click(freezeButton); + + // Updates coming in the same render as freezing are ignored + expect(canvas.getByTestId("frozen-state")).toHaveTextContent("Frozen"); + expect(canvas.getByTestId("prop-value")).toHaveTextContent("Prop: 0"); + expect(canvas.getByTestId("context-value")).toHaveTextContent("Context: 0"); + + // Frozen content stays visible even though it's suspended + expect(canvas.getByTestId("prop-value")).toBeVisible(); + expect(canvas.getByTestId("context-value")).toBeVisible(); + + await userEvent.click(freezeButton); + expect(canvas.getByTestId("prop-value")).toHaveTextContent("Prop: 0"); + + await userEvent.click(unfreezeButton); + + await waitFor(() => { + expect(canvas.getByTestId("frozen-state")).toHaveTextContent("Active"); + expect(canvas.getByTestId("prop-value")).toHaveTextContent("Prop: 3"); + expect(canvas.getByTestId("context-value")).toHaveTextContent("Context: 3"); + }); + expect(canvas.getByTestId("prop-value")).toBeVisible(); + }, +}; + +export const transition: StoryObj = { + name: "test: freezing in a transition", + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const [freezeButton] = canvas.getAllByRole("button"); + + await userEvent.click(freezeButton); + + // Transitions can't commit a frozen render, so they shouldn't be blocked by it + await waitFor(() => { + expect(canvas.getByTestId("frozen-state")).toHaveTextContent("Frozen"); + expect(canvas.getByTestId("prop-value")).toHaveTextContent("Prop: 1"); + }); + expect(canvas.getByTestId("prop-value")).toBeVisible(); + }, +};