Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,68 @@ describe('TooltipDialog', () => {
expect(onCloseSpy).toHaveBeenCalled();
});
});

describe('keepMounted', () => {
it('keeps dialog mounted in the DOM while visually hidden', () => {
const { queryByRole, getByTestId } = render(
<Example keepMounted backdropProps={{ 'data-test-id': 'backdrop' } as any} />
);

// Closed (hidden) state: still mounted but visually hidden and aria-hidden on backdrop
const hiddenDialog = queryByRole('dialog', { hidden: true });

expect(hiddenDialog).not.toBeNull();
expect(getByTestId('backdrop')).toHaveAttribute('aria-hidden', 'true');
// Backdrop should have hideVisually styles applied (position: absolute, clip, etc.)
const backdropStyles = window.getComputedStyle(getByTestId('backdrop'));
expect(backdropStyles.position).toBe('absolute');
expect(backdropStyles.width).toBe('1px');
expect(backdropStyles.height).toBe('1px');
});

it('toggles visibility and manages focus correctly when reopened', async () => {
const { getByText, getByRole, queryByRole, getByTestId } = render(
<Example keepMounted backdropProps={{ 'data-test-id': 'backdrop' } as any} />
);

const trigger = getByText('open');

// Initially hidden but mounted
const initiallyHiddenDialog = queryByRole('dialog', { hidden: true });
expect(initiallyHiddenDialog).not.toBeNull();
expect(getByTestId('backdrop')).toHaveAttribute('aria-hidden', 'true');

// Open
await act(async () => {
await user.click(trigger);
});

const openDialog = getByRole('dialog');
expect(openDialog).toBeInTheDocument();
expect(getByTestId('backdrop')).not.toHaveAttribute('aria-hidden');
expect(openDialog).toHaveFocus();
// Backdrop should NOT have hideVisually styles when visible
const visibleBackdropStyles = window.getComputedStyle(getByTestId('backdrop'));
expect(visibleBackdropStyles.position).toBe('fixed');

// Close (toggle button again)
await act(async () => {
await user.click(trigger);
});

// Dialog remains mounted but visually hidden again
const hiddenAgainDialog = queryByRole('dialog', { hidden: true });
expect(hiddenAgainDialog).not.toBeNull();
await waitFor(() => {
expect(getByTestId('backdrop')).toHaveAttribute('aria-hidden', 'true');
// Backdrop should have hideVisually styles applied again
const hiddenBackdropStyles = window.getComputedStyle(getByTestId('backdrop'));
expect(hiddenBackdropStyles.position).toBe('absolute');
expect(hiddenBackdropStyles.width).toBe('1px');
expect(hiddenBackdropStyles.height).toBe('1px');
});
// Focus should return to trigger
expect(trigger).toHaveFocus();
});
});
});
18 changes: 17 additions & 1 deletion packages/modals/src/elements/TooltipDialog/TooltipDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ import { createPortal } from 'react-dom';

const PLACEMENT_DEFAULT = 'top';

/**
* 1. When content is kept mounted we must manually focus on re-open
* 2. Hide only at 'exited' so exit animations finish
* and floating-ui sizing/focus logic remain valid during 'exiting'.
* Earlier hiding would cut animation and risk focus/layout issues.
*/
const TooltipDialogComponent = React.forwardRef<HTMLDivElement, ITooltipDialogProps>(
(
{
Expand All @@ -46,6 +52,7 @@ const TooltipDialogComponent = React.forwardRef<HTMLDivElement, ITooltipDialogPr
offset: _offset,
onClose,
hasArrow = true,
keepMounted,
isAnimated,
zIndex,
backdropProps,
Expand Down Expand Up @@ -148,19 +155,27 @@ const TooltipDialogComponent = React.forwardRef<HTMLDivElement, ITooltipDialogPr

const Node = (
<CSSTransition
unmountOnExit
unmountOnExit={!keepMounted}
timeout={isAnimated ? 200 : 0}
in={Boolean(referenceElement)}
classNames={isAnimated ? 'garden-tooltip-modal-transition' : ''}
nodeRef={transitionRef}
onEntered={() => {
if (keepMounted && focusOnMount && modalRef.current) {
modalRef.current.focus(); // [1]
}
}}
>
{transitionState => {
const isHidden = keepMounted && transitionState === 'exited'; // [2]

return (
<TooltipDialogContext.Provider value={value}>
<StyledTooltipDialogBackdrop
{...(getBackdropProps() as HTMLAttributes<HTMLDivElement>)}
{...backdropProps}
ref={transitionRef}
aria-hidden={isHidden ? true : undefined}
>
<StyledTooltipWrapper
ref={setFloatingElement}
Expand Down Expand Up @@ -203,6 +218,7 @@ TooltipDialogComponent.propTypes = {
),
isAnimated: PropTypes.bool,
hasArrow: PropTypes.bool,
keepMounted: PropTypes.bool,
zIndex: PropTypes.number,
onClose: PropTypes.func,
backdropProps: PropTypes.any,
Expand Down
3 changes: 3 additions & 0 deletions packages/modals/src/styled/StyledTooltipDialogBackdrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import styled from 'styled-components';
import { hideVisually } from 'polished';
import { componentStyles } from '@zendeskgarden/react-theming';

const COMPONENT_ID = 'modals.tooltip_dialog.backdrop';
Expand Down Expand Up @@ -34,5 +35,7 @@ export const StyledTooltipDialogBackdrop = styled.div.attrs({
opacity: 0;
}

${props => props['aria-hidden'] && hideVisually()}

${componentStyles};
`;
4 changes: 4 additions & 0 deletions packages/modals/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export interface ITooltipDialogProps extends Omit<IModalProps, 'isCentered' | 'i
* Adds an arrow to the tooltop
*/
hasArrow?: boolean;
/**
* Keeps the tooltip content mounted in the DOM when closed, rather than unmounting it
*/
keepMounted?: boolean;
/** @ignore Modifies the placement offset from the reference element (internal only) */
offset?: number;
/**
Expand Down