Skip to content

Commit f6853f4

Browse files
authored
feat(modals): support keeping TooltipDialog mounted when closed (#2050)
1 parent ed76446 commit f6853f4

File tree

4 files changed

+88
-1
lines changed

4 files changed

+88
-1
lines changed

packages/modals/src/elements/TooltipDialog/TooltipDialog.spec.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,68 @@ describe('TooltipDialog', () => {
232232
expect(onCloseSpy).toHaveBeenCalled();
233233
});
234234
});
235+
236+
describe('keepMounted', () => {
237+
it('keeps dialog mounted in the DOM while visually hidden', () => {
238+
const { queryByRole, getByTestId } = render(
239+
<Example keepMounted backdropProps={{ 'data-test-id': 'backdrop' } as any} />
240+
);
241+
242+
// Closed (hidden) state: still mounted but visually hidden and aria-hidden on backdrop
243+
const hiddenDialog = queryByRole('dialog', { hidden: true });
244+
245+
expect(hiddenDialog).not.toBeNull();
246+
expect(getByTestId('backdrop')).toHaveAttribute('aria-hidden', 'true');
247+
// Backdrop should have hideVisually styles applied (position: absolute, clip, etc.)
248+
const backdropStyles = window.getComputedStyle(getByTestId('backdrop'));
249+
expect(backdropStyles.position).toBe('absolute');
250+
expect(backdropStyles.width).toBe('1px');
251+
expect(backdropStyles.height).toBe('1px');
252+
});
253+
254+
it('toggles visibility and manages focus correctly when reopened', async () => {
255+
const { getByText, getByRole, queryByRole, getByTestId } = render(
256+
<Example keepMounted backdropProps={{ 'data-test-id': 'backdrop' } as any} />
257+
);
258+
259+
const trigger = getByText('open');
260+
261+
// Initially hidden but mounted
262+
const initiallyHiddenDialog = queryByRole('dialog', { hidden: true });
263+
expect(initiallyHiddenDialog).not.toBeNull();
264+
expect(getByTestId('backdrop')).toHaveAttribute('aria-hidden', 'true');
265+
266+
// Open
267+
await act(async () => {
268+
await user.click(trigger);
269+
});
270+
271+
const openDialog = getByRole('dialog');
272+
expect(openDialog).toBeInTheDocument();
273+
expect(getByTestId('backdrop')).not.toHaveAttribute('aria-hidden');
274+
expect(openDialog).toHaveFocus();
275+
// Backdrop should NOT have hideVisually styles when visible
276+
const visibleBackdropStyles = window.getComputedStyle(getByTestId('backdrop'));
277+
expect(visibleBackdropStyles.position).toBe('fixed');
278+
279+
// Close (toggle button again)
280+
await act(async () => {
281+
await user.click(trigger);
282+
});
283+
284+
// Dialog remains mounted but visually hidden again
285+
const hiddenAgainDialog = queryByRole('dialog', { hidden: true });
286+
expect(hiddenAgainDialog).not.toBeNull();
287+
await waitFor(() => {
288+
expect(getByTestId('backdrop')).toHaveAttribute('aria-hidden', 'true');
289+
// Backdrop should have hideVisually styles applied again
290+
const hiddenBackdropStyles = window.getComputedStyle(getByTestId('backdrop'));
291+
expect(hiddenBackdropStyles.position).toBe('absolute');
292+
expect(hiddenBackdropStyles.width).toBe('1px');
293+
expect(hiddenBackdropStyles.height).toBe('1px');
294+
});
295+
// Focus should return to trigger
296+
expect(trigger).toHaveFocus();
297+
});
298+
});
235299
});

packages/modals/src/elements/TooltipDialog/TooltipDialog.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ import { createPortal } from 'react-dom';
3636

3737
const PLACEMENT_DEFAULT = 'top';
3838

39+
/**
40+
* 1. When content is kept mounted we must manually focus on re-open
41+
* 2. Hide only at 'exited' so exit animations finish
42+
* and floating-ui sizing/focus logic remain valid during 'exiting'.
43+
* Earlier hiding would cut animation and risk focus/layout issues.
44+
*/
3945
const TooltipDialogComponent = React.forwardRef<HTMLDivElement, ITooltipDialogProps>(
4046
(
4147
{
@@ -46,6 +52,7 @@ const TooltipDialogComponent = React.forwardRef<HTMLDivElement, ITooltipDialogPr
4652
offset: _offset,
4753
onClose,
4854
hasArrow = true,
55+
keepMounted,
4956
isAnimated,
5057
zIndex,
5158
backdropProps,
@@ -148,19 +155,27 @@ const TooltipDialogComponent = React.forwardRef<HTMLDivElement, ITooltipDialogPr
148155

149156
const Node = (
150157
<CSSTransition
151-
unmountOnExit
158+
unmountOnExit={!keepMounted}
152159
timeout={isAnimated ? 200 : 0}
153160
in={Boolean(referenceElement)}
154161
classNames={isAnimated ? 'garden-tooltip-modal-transition' : ''}
155162
nodeRef={transitionRef}
163+
onEntered={() => {
164+
if (keepMounted && focusOnMount && modalRef.current) {
165+
modalRef.current.focus(); // [1]
166+
}
167+
}}
156168
>
157169
{transitionState => {
170+
const isHidden = keepMounted && transitionState === 'exited'; // [2]
171+
158172
return (
159173
<TooltipDialogContext.Provider value={value}>
160174
<StyledTooltipDialogBackdrop
161175
{...(getBackdropProps() as HTMLAttributes<HTMLDivElement>)}
162176
{...backdropProps}
163177
ref={transitionRef}
178+
aria-hidden={isHidden ? true : undefined}
164179
>
165180
<StyledTooltipWrapper
166181
ref={setFloatingElement}
@@ -203,6 +218,7 @@ TooltipDialogComponent.propTypes = {
203218
),
204219
isAnimated: PropTypes.bool,
205220
hasArrow: PropTypes.bool,
221+
keepMounted: PropTypes.bool,
206222
zIndex: PropTypes.number,
207223
onClose: PropTypes.func,
208224
backdropProps: PropTypes.any,

packages/modals/src/styled/StyledTooltipDialogBackdrop.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import styled from 'styled-components';
9+
import { hideVisually } from 'polished';
910
import { componentStyles } from '@zendeskgarden/react-theming';
1011

1112
const COMPONENT_ID = 'modals.tooltip_dialog.backdrop';
@@ -34,5 +35,7 @@ export const StyledTooltipDialogBackdrop = styled.div.attrs({
3435
opacity: 0;
3536
}
3637
38+
${props => props['aria-hidden'] && hideVisually()}
39+
3740
${componentStyles};
3841
`;

packages/modals/src/types/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ export interface ITooltipDialogProps extends Omit<IModalProps, 'isCentered' | 'i
8383
* Adds an arrow to the tooltop
8484
*/
8585
hasArrow?: boolean;
86+
/**
87+
* Keeps the tooltip content mounted in the DOM when closed, rather than unmounting it
88+
*/
89+
keepMounted?: boolean;
8690
/** @ignore Modifies the placement offset from the reference element (internal only) */
8791
offset?: number;
8892
/**

0 commit comments

Comments
 (0)