@navikt/ds-react
Version:
React components from the Norwegian Labour and Welfare Administration.
146 lines (127 loc) • 4.73 kB
text/typescript
"use client";
import { useMemo, useState } from "react";
import ReactDOM from "react-dom";
import { useClientLayoutEffect } from "../../../util";
type TransitionStatus = "entering" | "exiting" | "idle" | undefined;
/**
* Transition status state machine for components that animate between an
* `open` boolean and actual mount/unmount. Keeps the element mounted while
* exit animations run and introduces an optional stable `idle` phase.
*
* Adapted from MUI Base + Floating UI examples:
* - Source: https://github.com/mui/base-ui/blob/6fd69008d83561dbe75ff89acf270f0fac3e0049/packages/react/src/utils/useTransitionStatus.ts
* - Originally based on https://github.com/floating-ui/floating-ui/blob/7c33a3d0198a9b523d54ae2c37cedb315a309452/packages/react/src/hooks/useTransition.ts
*
* States (transitionStatus):
* - "entering" : just entered (initial frame of enter) OR re-entering to reach "idle".
* - "idle" : stable open (only when `enableIdleState === true`).
* - "exiting" : exit animation is running; element still mounted.
* - undefined : closed (unmounted) OR stable open when idle state is disabled.
* When `enableIdleState` is false we clear the label after
* the first frame so styling can rely purely on `mounted`.
*
* Distinction:
* - `mounted` tells you whether to render DOM.
* - `transitionStatus` tells you which phase-specific classes to apply.
*
* Frame separation: state changes that must trigger CSS transitions are
* scheduled with `requestAnimationFrame` so the browser sees distinct style
* mutations across frames (avoids missed transitions due to batching).
*
* @param open Controls visibility lifecycle.
* @param enableIdleState Insert a persistent "idle" phase after entering.
* @param deferExitingState Delay entering the exit phase by one frame to allow
* measurement / layout work before applying exit styles.
*/
function useTransitionStatus(
open: boolean,
enableIdleState: boolean = false,
deferExitingState: boolean = false,
) {
const [mounted, setMounted] = useState(open);
const [transitionStatus, setTransitionStatus] = useState<TransitionStatus>(
open && enableIdleState ? "idle" : undefined,
);
/* Opening: mount immediately and label as "entering" for the first frame. */
if (open && !mounted) {
setMounted(true);
setTransitionStatus("entering");
}
/* Closing (no defer): begin exit animation right away. */
if (
!open &&
mounted &&
transitionStatus !== "exiting" &&
!deferExitingState
) {
setTransitionStatus("exiting");
}
/* Cleanup: after unmount post-exit ensure status returns to undefined. */
if (!open && !mounted && transitionStatus === "exiting") {
setTransitionStatus(undefined);
}
/* Deferred closing: provide one frame to measure / flush layout before exit styles. */
useClientLayoutEffect(() => {
if (
!open &&
mounted &&
transitionStatus !== "exiting" &&
deferExitingState
) {
const frame = requestAnimationFrame(() => {
setTransitionStatus("exiting");
});
return () => {
cancelAnimationFrame(frame);
};
}
return undefined;
}, [open, mounted, transitionStatus, deferExitingState]);
/* Enter (no idle): hold "entering" for one frame, then clear label (stable open). */
useClientLayoutEffect(() => {
if (!open || enableIdleState) {
return undefined;
}
const frame = requestAnimationFrame(() => {
ReactDOM.flushSync(() => {
setTransitionStatus(undefined);
});
});
return () => {
cancelAnimationFrame(frame);
};
}, [enableIdleState, open]);
useClientLayoutEffect(() => {
if (!open || !enableIdleState) {
return undefined;
}
/*
* We can enter this state by re-opening before exiting-state was finished
* (open → close → open in quick succession).
*/
if (open && mounted && transitionStatus !== "idle") {
setTransitionStatus("entering");
}
/**
* Next frame, after "entering", switch to persistent "idle" state.
* By delaying to the next frame we ensure any "entering" styles are
* applied and the transition can run.
*/
const frame = requestAnimationFrame(() => {
setTransitionStatus("idle");
});
return () => {
cancelAnimationFrame(frame);
};
}, [enableIdleState, open, mounted, setTransitionStatus, transitionStatus]);
return useMemo(
() => ({
mounted,
setMounted,
transitionStatus,
}),
[mounted, transitionStatus],
);
}
export { useTransitionStatus };
export type { TransitionStatus };