Skip to content

Commit c677588

Browse files
committed
Cancel transition if initially open
1 parent a48f8bb commit c677588

File tree

2 files changed

+125
-56
lines changed

2 files changed

+125
-56
lines changed

docs/src/app/(private)/experiments/collapsible/plain.tsx

+77-24
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,61 @@ import classes from './plain.module.css';
55

66
import { useAnimationsFinished } from '../../../../../../packages/react/src/utils/useAnimationsFinished';
77
import { useEventCallback } from '../../../../../../packages/react/src/utils/useEventCallback';
8+
import { useForkRef } from '../../../../../../packages/react/src/utils/useForkRef';
89

9-
function PlainCollapsible(props: { keepMounted?: boolean }) {
10-
const { keepMounted = true } = props;
10+
const DEFAULT_OPEN = true;
1111

12-
const [open, setOpen] = React.useState(false);
12+
function PlainCollapsible(props: { defaultOpen?: boolean; keepMounted?: boolean }) {
13+
const { keepMounted = true, defaultOpen = false } = props;
14+
15+
const [open, setOpen] = React.useState(defaultOpen);
1316

1417
const [mounted, setMounted] = React.useState(open);
1518

19+
const [height, setHeight] = React.useState<number | undefined>(undefined);
20+
21+
const isInitiallyOpen = React.useRef(open);
22+
23+
const isHidden = React.useMemo(() => {
24+
if (keepMounted) {
25+
return !open;
26+
}
27+
28+
return !open && !mounted;
29+
}, [keepMounted, open, mounted]);
30+
1631
const panelRef: React.RefObject<HTMLElement | null> = React.useRef(null);
1732

18-
const [height, setHeight] = React.useState(0);
33+
const handlePanelRef = useEventCallback((element: HTMLElement) => {
34+
if (!element) {
35+
return;
36+
}
37+
38+
element.style.setProperty('display', 'block', 'important');
39+
40+
if (height === undefined) {
41+
setHeight(element.scrollHeight);
42+
43+
if (isInitiallyOpen.current) {
44+
element.style.transitionDuration = '0s';
45+
46+
requestAnimationFrame(() => {
47+
setTimeout(() => {
48+
element.style.transitionDuration = '';
49+
if (!keepMounted) {
50+
isInitiallyOpen.current = false;
51+
}
52+
});
53+
});
54+
}
55+
}
56+
});
57+
58+
const mergedRef = useForkRef(panelRef, handlePanelRef);
59+
60+
const abortControllerRef = React.useRef<AbortController | null>(null);
61+
62+
const runOnceAnimationsFinish = useAnimationsFinished(panelRef, false);
1963

2064
const handleTrigger = useEventCallback(() => {
2165
const nextOpen = !open;
@@ -33,11 +77,15 @@ function PlainCollapsible(props: { keepMounted?: boolean }) {
3377
return;
3478
}
3579

36-
// panel.style.display = 'block';
37-
3880
// const targetHeight = panel.clientHeight;
81+
panel.style.setProperty('display', 'block', 'important');
3982

4083
if (nextOpen) {
84+
if (abortControllerRef.current != null) {
85+
abortControllerRef.current.abort();
86+
abortControllerRef.current = null;
87+
}
88+
4189
/* opening */
4290
panel.style.opacity = '0';
4391
panel.style.height = '0px';
@@ -50,28 +98,37 @@ function PlainCollapsible(props: { keepMounted?: boolean }) {
5098
} else {
5199
/* closing */
52100
requestAnimationFrame(() => {
53-
// console.log('closing, scrollHeight', panel.scrollHeight);
54101
panel.style.opacity = '0';
55102
setHeight(0);
56103
});
104+
105+
abortControllerRef.current = new AbortController();
106+
107+
runOnceAnimationsFinish(() => {
108+
panel.style.setProperty('display', 'none');
109+
}, abortControllerRef.current.signal);
57110
}
58111
});
59112

60-
const runOnceAnimationsFinish = useAnimationsFinished(panelRef);
61-
62113
useEnhancedEffect(() => {
63114
// This only matters when `keepMounted={false}`
64115
if (keepMounted) {
65116
return;
66117
}
67-
// console.log('useEnhancedEffect open', open, 'mounted', mounted);
68118

69119
const panel = panelRef.current;
70120
if (!panel) {
71121
return;
72122
}
73123

74124
if (open) {
125+
// console.log('mounted?', mounted);
126+
127+
if (abortControllerRef.current != null) {
128+
abortControllerRef.current.abort();
129+
abortControllerRef.current = null;
130+
}
131+
75132
/* opening */
76133
panel.style.opacity = '0';
77134
panel.style.height = '0px';
@@ -81,34 +138,30 @@ function PlainCollapsible(props: { keepMounted?: boolean }) {
81138
panel.style.height = '';
82139
setHeight(panel.scrollHeight);
83140
});
141+
142+
setMounted(true);
84143
} else {
85144
/* closing */
86145
requestAnimationFrame(() => {
87-
console.log('closing, scrollHeight', panel.scrollHeight);
88146
panel.style.opacity = '0';
89147
setHeight(0);
90148
});
91149

150+
abortControllerRef.current = new AbortController();
151+
92152
runOnceAnimationsFinish(() => {
93153
setMounted(false);
94-
});
154+
}, abortControllerRef.current.signal);
95155
}
96156
}, [keepMounted, open, mounted, setMounted, runOnceAnimationsFinish]);
97157

98-
const isHidden = React.useMemo(() => {
99-
if (keepMounted) {
100-
return !open;
101-
}
102-
103-
return !open && !mounted;
104-
}, [keepMounted, open, mounted]);
105-
106158
return (
107159
<div
108160
className={classes.Root}
109161
style={{
110162
// @ts-ignore
111-
'--collapsible-panel-height': `${height}px`,
163+
'--collapsible-panel-height':
164+
height !== undefined ? `${height}px` : undefined,
112165
}}
113166
>
114167
<button
@@ -124,7 +177,7 @@ function PlainCollapsible(props: { keepMounted?: boolean }) {
124177
{(keepMounted || (!keepMounted && mounted)) && (
125178
<div
126179
// @ts-ignore
127-
ref={panelRef}
180+
ref={mergedRef}
128181
className={classes.Panel}
129182
{...{ [open ? 'data-open' : 'data-closed']: '' }}
130183
hidden={isHidden}
@@ -147,9 +200,9 @@ function PlainCollapsible(props: { keepMounted?: boolean }) {
147200
export default function App() {
148201
return (
149202
<div className={classes.wrapper}>
150-
<PlainCollapsible />
203+
<PlainCollapsible defaultOpen={DEFAULT_OPEN} />
151204

152-
<PlainCollapsible keepMounted={false} />
205+
<PlainCollapsible keepMounted={false} defaultOpen={DEFAULT_OPEN} />
153206
</div>
154207
);
155208
}

packages/react/src/utils/useAnimationsFinished.ts

+48-32
Original file line numberDiff line numberDiff line change
@@ -23,38 +23,54 @@ export function useAnimationsFinished(
2323

2424
React.useEffect(() => cancelTasks, [cancelTasks]);
2525

26-
return useEventCallback((fnToExecute: () => void) => {
27-
cancelTasks();
28-
29-
const element = ref.current;
30-
31-
if (!element) {
32-
return;
33-
}
34-
35-
if (typeof element.getAnimations !== 'function' || globalThis.BASE_UI_ANIMATIONS_DISABLED) {
36-
fnToExecute();
37-
} else {
38-
frameRef.current = requestAnimationFrame(() => {
39-
function exec() {
40-
if (!element) {
41-
return;
26+
return useEventCallback(
27+
(
28+
/**
29+
* A function to execute once all animations have finished.
30+
*/
31+
fnToExecute: () => void,
32+
/**
33+
* An optional [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that
34+
* can be used to abort `fnToExecute` before all the animations have finished.
35+
* @default null
36+
*/
37+
signal: AbortSignal | null = null,
38+
) => {
39+
cancelTasks();
40+
41+
const element = ref.current;
42+
43+
if (!element) {
44+
return;
45+
}
46+
47+
if (typeof element.getAnimations !== 'function' || globalThis.BASE_UI_ANIMATIONS_DISABLED) {
48+
fnToExecute();
49+
} else {
50+
frameRef.current = requestAnimationFrame(() => {
51+
function exec() {
52+
if (!element) {
53+
return;
54+
}
55+
56+
Promise.allSettled(element.getAnimations().map((anim) => anim.finished)).then(() => {
57+
if (signal != null && signal.aborted) {
58+
return;
59+
}
60+
// Synchronously flush the unmounting of the component so that the browser doesn't
61+
// paint: https://github.com/mui/base-ui/issues/979
62+
ReactDOM.flushSync(fnToExecute);
63+
});
4264
}
4365

44-
Promise.allSettled(element.getAnimations().map((anim) => anim.finished)).then(() => {
45-
// Synchronously flush the unmounting of the component so that the browser doesn't
46-
// paint: https://github.com/mui/base-ui/issues/979
47-
ReactDOM.flushSync(fnToExecute);
48-
});
49-
}
50-
51-
// `open: true` animations need to wait for the next tick to be detected
52-
if (waitForNextTick) {
53-
timeoutRef.current = window.setTimeout(exec);
54-
} else {
55-
exec();
56-
}
57-
});
58-
}
59-
});
66+
// `open: true` animations need to wait for the next tick to be detected
67+
if (waitForNextTick) {
68+
timeoutRef.current = window.setTimeout(exec);
69+
} else {
70+
exec();
71+
}
72+
});
73+
}
74+
},
75+
);
6076
}

0 commit comments

Comments
 (0)