Toggletip: Ensure consistent positioning in all scenarios (#114085)

* portal toggletip content

* add test to check focus trapping

* kick CI
This commit is contained in:
Ashley Harrison
2025-11-19 12:53:21 +00:00
committed by GitHub
parent 3f008acde2
commit 341a32266d
3 changed files with 72 additions and 47 deletions
@@ -140,7 +140,7 @@ describe('Toggletip', () => {
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('should be able to focus toggletip content next in DOM order - forwards and backwards', async () => {
it('should trap content within the overlay', async () => {
const onClose = jest.fn();
const afterInDom = 'Outside of toggletip';
@@ -164,17 +164,39 @@ describe('Toggletip', () => {
const closeButton = screen.getByTestId('toggletip-header-close');
expect(closeButton).toHaveFocus();
// focus after
await userEvent.tab();
expect(afterButton).toHaveFocus();
// tab forwards
await userEvent.keyboard('{tab}');
// need to waitFor here to wait for the floating-ui focus manager to take effect
await waitFor(() => {
expect(closeButton).toHaveFocus();
});
expect(afterButton).not.toHaveFocus();
// focus backwards
await userEvent.tab({ shift: true });
expect(closeButton).toHaveFocus();
// tab forwards again
await userEvent.keyboard('{tab}');
// need to waitFor here to wait for the floating-ui focus manager to take effect
await waitFor(() => {
expect(closeButton).toHaveFocus();
});
expect(afterButton).not.toHaveFocus();
// focus back to togglebutton
await userEvent.tab({ shift: true });
// tab backwards
await userEvent.keyboard('{shift}{tab}');
// need to waitFor here to wait for the floating-ui focus manager to take effect
await waitFor(() => {
expect(closeButton).toHaveFocus();
});
expect(afterButton).not.toHaveFocus();
// close overlay, focus back to togglebutton
await userEvent.keyboard('{escape}');
expect(button).toHaveFocus();
expect(afterButton).not.toHaveFocus();
// tab forwards with overlay closed
await userEvent.tab();
expect(closeButton).not.toHaveFocus();
expect(afterButton).toHaveFocus();
});
describe('Focus state', () => {
@@ -20,6 +20,7 @@ import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
import { getPositioningMiddleware } from '../../utils/floating';
import { buildTooltipTheme, getPlacement } from '../../utils/tooltipUtils';
import { IconButton } from '../IconButton/IconButton';
import { Portal } from '../Portal/Portal';
import { ToggletipContent } from './types';
@@ -118,44 +119,46 @@ export const Toggletip = memo(
...getReferenceProps(),
})}
{isOpen && (
<FloatingFocusManager context={context} modal={false} closeOnFocusOut={false}>
<div
data-testid="toggletip-content"
className={cx(style.container, {
[styles.fitContent]: fitContent,
})}
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
<FloatingArrow
strokeWidth={0.3}
stroke={grafanaTheme.colors.border.weak}
className={style.arrow}
ref={arrowRef}
context={context}
/>
{Boolean(title) && <div className={style.header}>{title}</div>}
{closeButton && (
<div className={style.headerClose}>
<IconButton
aria-label={t('grafana-ui.toggletip.close', 'Close')}
name="times"
data-testid="toggletip-header-close"
onClick={() => {
setControlledVisible(false);
onClose?.();
}}
/>
<Portal>
<FloatingFocusManager context={context} modal={true}>
<div
data-testid="toggletip-content"
className={cx(style.container, {
[styles.fitContent]: fitContent,
})}
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
<FloatingArrow
strokeWidth={0.3}
stroke={grafanaTheme.colors.border.weak}
className={style.arrow}
ref={arrowRef}
context={context}
/>
{Boolean(title) && <div className={style.header}>{title}</div>}
{closeButton && (
<div className={style.headerClose}>
<IconButton
aria-label={t('grafana-ui.toggletip.close', 'Close')}
name="times"
data-testid="toggletip-header-close"
onClick={() => {
setControlledVisible(false);
onClose?.();
}}
/>
</div>
)}
<div className={style.body}>
{(typeof content === 'string' || isValidElement(content)) && content}
{typeof content === 'function' && content({})}
</div>
)}
<div className={style.body}>
{(typeof content === 'string' || isValidElement(content)) && content}
{typeof content === 'function' && content({})}
{Boolean(footer) && <div className={style.footer}>{footer}</div>}
</div>
{Boolean(footer) && <div className={style.footer}>{footer}</div>}
</div>
</FloatingFocusManager>
</FloatingFocusManager>
</Portal>
)}
</>
);
@@ -51,8 +51,8 @@ export function buildTooltipTheme(
headerClose: css({
color: theme.colors.text.secondary,
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1.5),
right: theme.spacing(0.5),
top: theme.spacing(1),
backgroundColor: 'transparent',
border: 0,
}),