Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Collapsible] Rework CSS transitions #1549

Draft
wants to merge 31 commits into
base: master
Choose a base branch
from
Draft
Changes from 1 commit
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
239c527
Add experiment
mj12albert Mar 10, 2025
d69830f
Support keepMounted false
mj12albert Mar 10, 2025
96644b6
Support hidden attribute
mj12albert Mar 10, 2025
37f2cc5
Fix Tailwind hidden attr default
mj12albert Mar 10, 2025
3c7c6a6
Cancel transition if initially open
mj12albert Mar 10, 2025
3479f5a
Remove unnecessary opacity changes
mj12albert Mar 11, 2025
d362b38
useTransitionStatus
mj12albert Mar 11, 2025
7904aa3
No flag
mj12albert Mar 11, 2025
6c96edd
Remove more manual opacity changes.
mj12albert Mar 11, 2025
149e15a
Tweak style tag cleanup
mj12albert Mar 11, 2025
4888f86
keepMounted true works well
mj12albert Mar 11, 2025
ce81c53
WIP other transitions wwhen keepMounted false
mj12albert Mar 11, 2025
72acf3e
WIP other transition properties
mj12albert Mar 11, 2025
0ffb27a
WIP handle other transition props
mj12albert Mar 12, 2025
57c2fe6
drop isInitiallyOpenRef
mj12albert Mar 12, 2025
2edc4eb
data-closed is not needed in CSS anymore
mj12albert Mar 12, 2025
669d9cf
remove extra flag on useTransitionStatus
mj12albert Mar 12, 2025
2fadd65
extend starting status
mj12albert Mar 12, 2025
b2836cd
remove one more manual opacity change
mj12albert Mar 12, 2025
c8cd8a4
a brilliant hack
mj12albert Mar 12, 2025
4841281
slight polish
mj12albert Mar 12, 2025
4754b49
rename
mj12albert Mar 12, 2025
ae5d2c8
Add demos
mj12albert Mar 12, 2025
e461391
Isolate CSS animation logic
mj12albert Mar 13, 2025
4743ec8
Merge reworked implementations
mj12albert Mar 18, 2025
cf7ddda
WIP rework hidden-until-found
mj12albert Mar 18, 2025
2c1b53f
WIP 2
mj12albert Mar 18, 2025
4fb41b4
WIP 3
mj12albert Mar 18, 2025
f8d0147
WIP 4
mj12albert Mar 19, 2025
00f99b8
CSS animation fixes
mj12albert Mar 19, 2025
5c9a159
Handle beforematch with CSS animations
mj12albert Mar 19, 2025
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
Prev Previous commit
Next Next commit
Cancel transition if initially open
mj12albert committed Mar 19, 2025
commit 3c7c6a60974e695294a175977df9a0e1809a5c8b
101 changes: 77 additions & 24 deletions docs/src/app/(private)/experiments/collapsible/plain.tsx
Original file line number Diff line number Diff line change
@@ -5,17 +5,61 @@ import classes from './plain.module.css';

import { useAnimationsFinished } from '../../../../../../packages/react/src/utils/useAnimationsFinished';
import { useEventCallback } from '../../../../../../packages/react/src/utils/useEventCallback';
import { useForkRef } from '../../../../../../packages/react/src/utils/useForkRef';

function PlainCollapsible(props: { keepMounted?: boolean }) {
const { keepMounted = true } = props;
const DEFAULT_OPEN = true;

const [open, setOpen] = React.useState(false);
function PlainCollapsible(props: { defaultOpen?: boolean; keepMounted?: boolean }) {
const { keepMounted = true, defaultOpen = false } = props;

const [open, setOpen] = React.useState(defaultOpen);

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

const [height, setHeight] = React.useState<number | undefined>(undefined);

const isInitiallyOpen = React.useRef(open);

const isHidden = React.useMemo(() => {
if (keepMounted) {
return !open;
}

return !open && !mounted;
}, [keepMounted, open, mounted]);

const panelRef: React.RefObject<HTMLElement | null> = React.useRef(null);

const [height, setHeight] = React.useState(0);
const handlePanelRef = useEventCallback((element: HTMLElement) => {
if (!element) {
return;
}

element.style.setProperty('display', 'block', 'important');

if (height === undefined) {
setHeight(element.scrollHeight);

if (isInitiallyOpen.current) {
element.style.transitionDuration = '0s';

requestAnimationFrame(() => {
setTimeout(() => {
element.style.transitionDuration = '';
if (!keepMounted) {
isInitiallyOpen.current = false;
}
});
});
}
}
});

const mergedRef = useForkRef(panelRef, handlePanelRef);

const abortControllerRef = React.useRef<AbortController | null>(null);

const runOnceAnimationsFinish = useAnimationsFinished(panelRef, false);

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

// panel.style.display = 'block';

// const targetHeight = panel.clientHeight;
panel.style.setProperty('display', 'block', 'important');

if (nextOpen) {
if (abortControllerRef.current != null) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}

/* opening */
panel.style.opacity = '0';
panel.style.height = '0px';
@@ -50,28 +98,37 @@ function PlainCollapsible(props: { keepMounted?: boolean }) {
} else {
/* closing */
requestAnimationFrame(() => {
// console.log('closing, scrollHeight', panel.scrollHeight);
panel.style.opacity = '0';
setHeight(0);
});

abortControllerRef.current = new AbortController();

runOnceAnimationsFinish(() => {
panel.style.setProperty('display', 'none');
}, abortControllerRef.current.signal);
}
});

const runOnceAnimationsFinish = useAnimationsFinished(panelRef);

useEnhancedEffect(() => {
// This only matters when `keepMounted={false}`
if (keepMounted) {
return;
}
// console.log('useEnhancedEffect open', open, 'mounted', mounted);

const panel = panelRef.current;
if (!panel) {
return;
}

if (open) {
// console.log('mounted?', mounted);

if (abortControllerRef.current != null) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}

/* opening */
panel.style.opacity = '0';
panel.style.height = '0px';
@@ -81,34 +138,30 @@ function PlainCollapsible(props: { keepMounted?: boolean }) {
panel.style.height = '';
setHeight(panel.scrollHeight);
});

setMounted(true);
} else {
/* closing */
requestAnimationFrame(() => {
console.log('closing, scrollHeight', panel.scrollHeight);
panel.style.opacity = '0';
setHeight(0);
});

abortControllerRef.current = new AbortController();

runOnceAnimationsFinish(() => {
setMounted(false);
});
}, abortControllerRef.current.signal);
}
}, [keepMounted, open, mounted, setMounted, runOnceAnimationsFinish]);

const isHidden = React.useMemo(() => {
if (keepMounted) {
return !open;
}

return !open && !mounted;
}, [keepMounted, open, mounted]);

return (
<div
className={classes.Root}
style={{
// @ts-ignore
'--collapsible-panel-height': `${height}px`,
'--collapsible-panel-height':
height !== undefined ? `${height}px` : undefined,
}}
>
<button
@@ -124,7 +177,7 @@ function PlainCollapsible(props: { keepMounted?: boolean }) {
{(keepMounted || (!keepMounted && mounted)) && (
<div
// @ts-ignore
ref={panelRef}
ref={mergedRef}
className={classes.Panel}
{...{ [open ? 'data-open' : 'data-closed']: '' }}
hidden={isHidden}
@@ -147,9 +200,9 @@ function PlainCollapsible(props: { keepMounted?: boolean }) {
export default function App() {
return (
<div className={classes.wrapper}>
<PlainCollapsible />
<PlainCollapsible defaultOpen={DEFAULT_OPEN} />

<PlainCollapsible keepMounted={false} />
<PlainCollapsible keepMounted={false} defaultOpen={DEFAULT_OPEN} />
</div>
);
}
80 changes: 48 additions & 32 deletions packages/react/src/utils/useAnimationsFinished.ts
Original file line number Diff line number Diff line change
@@ -23,38 +23,54 @@ export function useAnimationsFinished(

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

return useEventCallback((fnToExecute: () => void) => {
cancelTasks();

const element = ref.current;

if (!element) {
return;
}

if (typeof element.getAnimations !== 'function' || globalThis.BASE_UI_ANIMATIONS_DISABLED) {
fnToExecute();
} else {
frameRef.current = requestAnimationFrame(() => {
function exec() {
if (!element) {
return;
return useEventCallback(
(
/**
* A function to execute once all animations have finished.
*/
fnToExecute: () => void,
/**
* An optional [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that
* can be used to abort `fnToExecute` before all the animations have finished.
* @default null
*/
signal: AbortSignal | null = null,
) => {
cancelTasks();

const element = ref.current;

if (!element) {
return;
}

if (typeof element.getAnimations !== 'function' || globalThis.BASE_UI_ANIMATIONS_DISABLED) {
fnToExecute();
} else {
frameRef.current = requestAnimationFrame(() => {
function exec() {
if (!element) {
return;
}

Promise.allSettled(element.getAnimations().map((anim) => anim.finished)).then(() => {
if (signal != null && signal.aborted) {
return;
}
// Synchronously flush the unmounting of the component so that the browser doesn't
// paint: https://github.com/mui/base-ui/issues/979
ReactDOM.flushSync(fnToExecute);
});
}

Promise.allSettled(element.getAnimations().map((anim) => anim.finished)).then(() => {
// Synchronously flush the unmounting of the component so that the browser doesn't
// paint: https://github.com/mui/base-ui/issues/979
ReactDOM.flushSync(fnToExecute);
});
}

// `open: true` animations need to wait for the next tick to be detected
if (waitForNextTick) {
timeoutRef.current = window.setTimeout(exec);
} else {
exec();
}
});
}
});
// `open: true` animations need to wait for the next tick to be detected
if (waitForNextTick) {
timeoutRef.current = window.setTimeout(exec);
} else {
exec();
}
});
}
},
);
}