Skip to content

Commit 8d1ba7e

Browse files
committed
feat(tearsheet): implement enablePresence feature
1 parent 1319e56 commit 8d1ba7e

File tree

7 files changed

+381
-18
lines changed

7 files changed

+381
-18
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
"@carbon/grid": "^11.45.0",
7171
"@carbon/layout": "^11.43.0",
7272
"@carbon/motion": "^11.37.0",
73-
"@carbon/react": "^1.95.0",
73+
"@carbon/react": "^1.97.0",
7474
"@carbon/themes": "^11.62.0",
7575
"@carbon/type": "^11.49.0",
7676
"@commitlint/cli": "^20.0.0",

packages/ibm-products/src/components/Tearsheet/Tearsheet.stories.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ export default {
140140
},
141141
label: { control: { type: 'text' } },
142142
title: { control: { type: 'text' } },
143+
enablePresence: {
144+
control: { type: 'boolean' },
145+
description:
146+
'Enable presence mode to remove DOM element after tearsheet exits (preserves animations)',
147+
},
143148
influencer: { control: { disable: true } },
144149
onClose: { control: { disable: true } },
145150
navigation: { control: { disable: true } },

packages/ibm-products/src/components/Tearsheet/Tearsheet.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ export interface TearsheetProps extends PropsWithChildren {
7575
*/
7676
description?: ReactNode;
7777

78+
/**
79+
* Specify whether the Tearsheet should opt in to presence mode.
80+
* When enabled, the Tearsheet will not mount until it is opened
81+
* and will unmount when it's closed, preserving exit animations.
82+
*/
83+
enablePresence?: boolean;
84+
7885
/**
7986
* Enable a close icon ('x') in the header area of the tearsheet. By default,
8087
* (when this prop is omitted, or undefined or null) a tearsheet does not
@@ -293,6 +300,13 @@ Tearsheet.propTypes = {
293300
*/
294301
description: PropTypes.node,
295302

303+
/**
304+
* Specify whether the Tearsheet should opt in to presence mode.
305+
* When enabled, the Tearsheet will not mount until it is opened
306+
* and will unmount when it's closed, preserving exit animations.
307+
*/
308+
enablePresence: PropTypes.bool,
309+
296310
/**
297311
* Enable a close icon ('x') in the header area of the tearsheet. By default,
298312
* (when this prop is omitted, or undefined or null) a tearsheet does not
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Copyright IBM Corp. 2020, 2024
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import React, {
9+
type PropsWithChildren,
10+
type ReactElement,
11+
useEffect,
12+
useRef,
13+
useState,
14+
} from 'react';
15+
import { PresenceContext } from './hooks/usePresenceContext';
16+
17+
interface TearsheetPresenceProps extends PropsWithChildren {
18+
/**
19+
* Specify whether the Tearsheet should opt in to presence mode.
20+
*/
21+
open: boolean;
22+
23+
/**
24+
* Internal property for backwards compatibility. Specify
25+
* whether the Tearsheet should opt in to presence mode.
26+
*/
27+
_autoEnablePresence?: boolean;
28+
29+
/**
30+
* Internal property to predefine the presence context's id
31+
* for exclusivity.
32+
*/
33+
_presenceId?: string;
34+
}
35+
36+
/**
37+
* TearsheetPresence is a wrapper component that manages the presence
38+
* of a Tearsheet in the DOM. When enabled, the Tearsheet will not mount
39+
* until it is opened and will unmount when it's closed, while preserving
40+
* exit animations.
41+
*/
42+
export const TearsheetPresence = ({
43+
open,
44+
_presenceId: presenceId,
45+
_autoEnablePresence: autoEnablePresence = true,
46+
children,
47+
}: TearsheetPresenceProps): ReactElement | null => {
48+
const [isPresent, setIsPresent] = useState(open);
49+
const hasAnimatedOut = useRef(false);
50+
51+
useEffect(() => {
52+
if (open) {
53+
setIsPresent(true);
54+
hasAnimatedOut.current = false;
55+
}
56+
}, [open]);
57+
58+
// Listen for animation end to remove from DOM
59+
useEffect(() => {
60+
if (!open && isPresent && !hasAnimatedOut.current) {
61+
const timer = setTimeout(() => {
62+
hasAnimatedOut.current = true;
63+
setIsPresent(false);
64+
}, 500); // Match the tearsheet animation duration
65+
66+
return () => clearTimeout(timer);
67+
}
68+
}, [open, isPresent]);
69+
70+
if (!isPresent) {
71+
return null;
72+
}
73+
74+
const presenceContextValue = {
75+
id: presenceId || `tearsheet-presence-${Date.now()}`,
76+
};
77+
78+
return (
79+
<PresenceContext.Provider value={presenceContextValue}>
80+
{children}
81+
</PresenceContext.Provider>
82+
);
83+
};

packages/ibm-products/src/components/Tearsheet/TearsheetShell.tsx

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import React, {
1010
useEffect,
1111
useState,
1212
useRef,
13+
useMemo,
1314
ComponentProps,
1415
PropsWithChildren,
1516
ReactNode,
1617
ForwardedRef,
1718
RefObject,
1819
} from 'react';
1920
import { useResizeObserver } from '../../global/js/hooks/useResizeObserver';
21+
import { usePresenceContext } from './hooks/usePresenceContext';
2022

2123
// Other standard imports.
2224
import PropTypes from 'prop-types';
@@ -61,6 +63,13 @@ interface TearsheetShellProps extends PropsWithChildren {
6163
*/
6264
className?: string;
6365

66+
/**
67+
* Specify whether the Tearsheet should opt in to presence mode.
68+
* When enabled, the Tearsheet will not mount until it is opened
69+
* and will unmount when it's closed, preserving exit animations.
70+
*/
71+
enablePresence?: boolean;
72+
6473
/**
6574
* The accessibility title for the close icon (if shown).
6675
*
@@ -263,6 +272,7 @@ export const TearsheetShell = React.forwardRef(
263272
closeIconDescription = 'Close',
264273
currentStep,
265274
description,
275+
enablePresence,
266276
hasCloseIcon,
267277
hasError,
268278
headerActions,
@@ -302,6 +312,12 @@ export const TearsheetShell = React.forwardRef(
302312
const modalRefValue = modalRef.current;
303313
const wide = size === 'wide';
304314

315+
// Use presence context - it handles enablePresence internally
316+
const { isPresent, shouldBeOpen, handleExitComplete } = usePresenceContext(
317+
open || false,
318+
enablePresence || false
319+
);
320+
305321
// Keep track of the stack depth and our position in it (1-based, 0=closed)
306322
const [depth, setDepth] = useState(0);
307323
const [position, setPosition] = useState(0);
@@ -356,6 +372,39 @@ export const TearsheetShell = React.forwardRef(
356372
}
357373
}, [claimFocus, hasError, modalRef]);
358374

375+
// Handle exit animation complete for enablePresence
376+
useEffect(() => {
377+
if (!enablePresence || !modalRef.current) {
378+
return;
379+
}
380+
381+
const handleTransitionEnd = (event: TransitionEvent) => {
382+
// Only handle transform transitions on the container element
383+
const containerElement = modalRef.current?.querySelector(
384+
`.${bc}__container`
385+
);
386+
if (
387+
event.target === containerElement &&
388+
event.propertyName === 'transform'
389+
) {
390+
handleExitComplete();
391+
}
392+
};
393+
394+
const element = modalRef.current;
395+
element.addEventListener(
396+
'transitionend',
397+
handleTransitionEnd as EventListener
398+
);
399+
400+
return () => {
401+
element.removeEventListener(
402+
'transitionend',
403+
handleTransitionEnd as EventListener
404+
);
405+
};
406+
}, [enablePresence, modalRef, handleExitComplete]);
407+
359408
useEffect(() => {
360409
const notify = () =>
361410
stack.all.forEach((handler) => {
@@ -420,6 +469,10 @@ export const TearsheetShell = React.forwardRef(
420469
}, [modalRef, width]);
421470

422471
if (position <= depth) {
472+
// If enablePresence is true and component is not present, don't render
473+
if (enablePresence && !isPresent) {
474+
return null;
475+
}
423476
// Include a modal header if and only if one or more of these is given.
424477
// We can't use a Wrap for the ModalHeader because ComposedModal requires
425478
// the direct child to be the ModalHeader instance.
@@ -463,7 +516,7 @@ export const TearsheetShell = React.forwardRef(
463516
[`${bc}__container--mixed-size-stacking`]:
464517
!areAllSameSizeVariant(),
465518
})}
466-
{...{ onClose, open, selectorPrimaryFocus }}
519+
{...{ onClose, open: shouldBeOpen, selectorPrimaryFocus }}
467520
onKeyDown={keyDownListener}
468521
preventCloseOnClickOutside={!isPassive}
469522
ref={modalRef}
@@ -690,6 +743,13 @@ TearsheetShell.propTypes = {
690743
*/
691744
description: PropTypes.node,
692745

746+
/**
747+
* Specify whether the Tearsheet should opt in to presence mode.
748+
* When enabled, the Tearsheet will not mount until it is opened
749+
* and will unmount when it's closed, preserving exit animations.
750+
*/
751+
enablePresence: PropTypes.bool,
752+
693753
/**
694754
* Enable a close icon ('x') in the header area of the tearsheet. By default,
695755
* (when this prop is omitted, or undefined or null) a tearsheet does not
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Copyright IBM Corp. 2020, 2024
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {
9+
createContext,
10+
useContext,
11+
useState,
12+
useCallback,
13+
useEffect,
14+
useRef,
15+
} from 'react';
16+
17+
export interface PresenceContextValue {
18+
id: string;
19+
}
20+
21+
export const PresenceContext = createContext<PresenceContextValue | null>(null);
22+
23+
export function usePresenceContext(
24+
open: boolean,
25+
enablePresence: boolean = false
26+
) {
27+
const context = useContext(PresenceContext);
28+
const [exitState, setExitState] = useState<'active' | 'idle' | 'finished'>(
29+
'finished' // Always start as finished
30+
);
31+
const [shouldBeOpen, setShouldBeOpen] = useState(open);
32+
const isInitialMount = useRef(true);
33+
34+
// Handle shouldBeOpen state - runs when open or enablePresence changes
35+
useEffect(() => {
36+
if (!enablePresence) {
37+
// When presence is disabled, immediately mirror open state
38+
setShouldBeOpen(open);
39+
return;
40+
}
41+
42+
if (open) {
43+
// On initial mount with open=true, delay shouldBeOpen to trigger animation
44+
if (isInitialMount.current) {
45+
isInitialMount.current = false;
46+
const timer = setTimeout(() => {
47+
setShouldBeOpen(true);
48+
}, 10);
49+
return () => clearTimeout(timer);
50+
} else {
51+
setShouldBeOpen(true);
52+
}
53+
} else {
54+
isInitialMount.current = false;
55+
setShouldBeOpen(false);
56+
}
57+
}, [open, enablePresence]);
58+
59+
// Handle exitState transitions - only when enablePresence is true
60+
useEffect(() => {
61+
if (!enablePresence) {
62+
return;
63+
}
64+
65+
if (open) {
66+
setExitState('active');
67+
} else if (exitState === 'active') {
68+
setExitState('idle');
69+
}
70+
}, [open, enablePresence, exitState]);
71+
72+
const handleExitComplete = useCallback(() => {
73+
if (!open) {
74+
setExitState('finished');
75+
}
76+
}, [open]);
77+
78+
// Component is present when open OR when exit animation hasn't finished
79+
// If enablePresence is false, always present
80+
const isPresent = context
81+
? true
82+
: !enablePresence || open || exitState !== 'finished';
83+
const isExiting = enablePresence && !open && exitState === 'idle';
84+
85+
return {
86+
isPresent,
87+
isExiting,
88+
shouldBeOpen,
89+
handleExitComplete,
90+
};
91+
}

0 commit comments

Comments
 (0)