mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
A11y: Fix toggletip predictable focus for keyboard users (#72100)
* Updated toggletip to use strategy fixed, added tests and temporary story so it can easily be checked * Added FocusScope restoreFocus and appropriate tests to toggletip * Open toggletip in test for making sure focus remains when using escape * Add aria-expanded to toggletip toggle child * Added to temp story for Toggletip * Remove focusScope for toggletip and handle focus restoration manually * Remove toggletip temp story and add toggletip long content story --------- Co-authored-by: joshhunt <josh@trtr.co>
This commit is contained in:
parent
cb040a72bd
commit
31e29de024
@ -64,7 +64,7 @@ function onClose() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Toogletip
|
<Toggletip
|
||||||
content="Toggletip body"
|
content="Toggletip body"
|
||||||
title="This is the title of the Toggletip"
|
title="This is the title of the Toggletip"
|
||||||
footer="Toggletip footer text"
|
footer="Toggletip footer text"
|
||||||
@ -72,7 +72,7 @@ return (
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<IconButton name="question-circle" tooltip="IconButton containing a Toggletip" />
|
<IconButton name="question-circle" tooltip="IconButton containing a Toggletip" />
|
||||||
</Toogletip>
|
</Toggletip>
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import { SelectableValue } from '@grafana/data';
|
|||||||
|
|
||||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||||
import { Button } from '../Button';
|
import { Button } from '../Button';
|
||||||
|
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||||
import { ButtonSelect } from '../Dropdown/ButtonSelect';
|
import { ButtonSelect } from '../Dropdown/ButtonSelect';
|
||||||
import { InlineField } from '../Forms/InlineField';
|
import { InlineField } from '../Forms/InlineField';
|
||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
@ -175,4 +176,54 @@ HostingMultiElements.args = {
|
|||||||
theme: 'info',
|
theme: 'info',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const LongContent: StoryFn<typeof Toggletip> = ({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
footer,
|
||||||
|
theme,
|
||||||
|
closeButton,
|
||||||
|
placement,
|
||||||
|
...args
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Toggletip
|
||||||
|
title={<h2>Toggletip with scrollable content and no interactive controls</h2>}
|
||||||
|
content={
|
||||||
|
<CustomScrollbar autoHeightMax="500px">
|
||||||
|
{/* one of the few documented cases we can turn this rule off */}
|
||||||
|
{/* https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-noninteractive-tabindex.md#case-shouldnt-i-add-a-tabindex-so-that-users-can-navigate-to-this-item */}
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
|
||||||
|
<div tabIndex={0}>
|
||||||
|
<p>
|
||||||
|
If for any reason you have to use a Toggletip with a lot of content with no interactive controls, set a{' '}
|
||||||
|
<code>tabIndex=0</code> attribute to the container so keyboard users are able to focus the content and
|
||||||
|
able to scroll up and down it.
|
||||||
|
</p>
|
||||||
|
{new Array(15).fill(undefined).map((_, i) => (
|
||||||
|
<p key={i}>This is some content repeated over and over again to ensure it is scrollable.</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CustomScrollbar>
|
||||||
|
}
|
||||||
|
footer={footer}
|
||||||
|
theme={theme}
|
||||||
|
placement={placement}
|
||||||
|
{...args}
|
||||||
|
>
|
||||||
|
<Button>Click to show Toggletip with long content!</Button>
|
||||||
|
</Toggletip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
LongContent.args = {
|
||||||
|
placement: 'auto',
|
||||||
|
theme: 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
LongContent.parameters = {
|
||||||
|
controls: {
|
||||||
|
hideNoControlsWarning: true,
|
||||||
|
exclude: ['title', 'content', 'children'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { act, render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
@ -7,7 +7,7 @@ import { Button } from '../Button';
|
|||||||
import { Toggletip } from './Toggletip';
|
import { Toggletip } from './Toggletip';
|
||||||
|
|
||||||
describe('Toggletip', () => {
|
describe('Toggletip', () => {
|
||||||
it('should display toogletip after click on "Click me!" button', async () => {
|
it('should display toggletip after click on "Click me!" button', async () => {
|
||||||
render(
|
render(
|
||||||
<Toggletip placement="auto" content="Tooltip text">
|
<Toggletip placement="auto" content="Tooltip text">
|
||||||
<Button type="button" data-testid="myButton">
|
<Button type="button" data-testid="myButton">
|
||||||
@ -22,7 +22,7 @@ describe('Toggletip', () => {
|
|||||||
expect(screen.getByTestId('toggletip-content')).toBeInTheDocument();
|
expect(screen.getByTestId('toggletip-content')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should close toogletip after click on close button', async () => {
|
it('should close toggletip after click on close button', async () => {
|
||||||
const closeSpy = jest.fn();
|
const closeSpy = jest.fn();
|
||||||
render(
|
render(
|
||||||
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
|
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
|
||||||
@ -43,7 +43,7 @@ describe('Toggletip', () => {
|
|||||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should close toogletip after press ESC', async () => {
|
it('should close toggletip after press ESC', async () => {
|
||||||
const closeSpy = jest.fn();
|
const closeSpy = jest.fn();
|
||||||
render(
|
render(
|
||||||
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
|
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
|
||||||
@ -62,7 +62,7 @@ describe('Toggletip', () => {
|
|||||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display the toogletip after press ENTER', async () => {
|
it('should display the toggletip after press ENTER', async () => {
|
||||||
const closeSpy = jest.fn();
|
const closeSpy = jest.fn();
|
||||||
render(
|
render(
|
||||||
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
|
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
|
||||||
@ -81,4 +81,109 @@ describe('Toggletip', () => {
|
|||||||
|
|
||||||
expect(screen.getByTestId('toggletip-content')).toBeInTheDocument();
|
expect(screen.getByTestId('toggletip-content')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should be able to focus toggletip content next in DOM order - forwards and backwards', async () => {
|
||||||
|
const closeSpy = jest.fn();
|
||||||
|
const afterInDom = 'Outside of toggletip';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
|
||||||
|
<Button type="button" data-testid="myButton">
|
||||||
|
Click me!
|
||||||
|
</Button>
|
||||||
|
</Toggletip>
|
||||||
|
<button>{afterInDom}</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('toggletip-content')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const button = screen.getByTestId('myButton');
|
||||||
|
const afterButton = screen.getByText(afterInDom);
|
||||||
|
await userEvent.click(button);
|
||||||
|
await userEvent.tab();
|
||||||
|
const closeButton = screen.getByTestId('toggletip-header-close');
|
||||||
|
expect(closeButton).toHaveFocus();
|
||||||
|
|
||||||
|
// focus after
|
||||||
|
await userEvent.tab();
|
||||||
|
expect(afterButton).toHaveFocus();
|
||||||
|
|
||||||
|
// focus backwards
|
||||||
|
await userEvent.tab({ shift: true });
|
||||||
|
expect(closeButton).toHaveFocus();
|
||||||
|
|
||||||
|
// focus back to togglebutton
|
||||||
|
await userEvent.tab({ shift: true });
|
||||||
|
expect(button).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Focus state', () => {
|
||||||
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
// Need to use delay: null here to work with fakeTimers
|
||||||
|
// see https://github.com/testing-library/user-event/issues/833
|
||||||
|
user = userEvent.setup({ delay: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restore focus to the button that opened the toggletip when closed from within the toggletip', async () => {
|
||||||
|
const closeSpy = jest.fn();
|
||||||
|
render(
|
||||||
|
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
|
||||||
|
<Button type="button" data-testid="myButton">
|
||||||
|
Click me!
|
||||||
|
</Button>
|
||||||
|
</Toggletip>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByTestId('myButton');
|
||||||
|
await user.click(button);
|
||||||
|
const closeButton = await screen.findByTestId('toggletip-header-close');
|
||||||
|
expect(closeButton).toBeInTheDocument();
|
||||||
|
await user.click(closeButton);
|
||||||
|
act(() => {
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(button).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT restore focus to the button that opened the toggletip when closed from outside the toggletip', async () => {
|
||||||
|
const closeSpy = jest.fn();
|
||||||
|
const afterInDom = 'Outside of toggletip';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
|
||||||
|
<Button type="button" data-testid="myButton">
|
||||||
|
Click me!
|
||||||
|
</Button>
|
||||||
|
</Toggletip>
|
||||||
|
<button>{afterInDom}</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByTestId('myButton');
|
||||||
|
await user.click(button);
|
||||||
|
const closeButton = await screen.findByTestId('toggletip-header-close');
|
||||||
|
|
||||||
|
expect(closeButton).toBeInTheDocument();
|
||||||
|
const afterButton = screen.getByText(afterInDom);
|
||||||
|
afterButton.focus();
|
||||||
|
|
||||||
|
await user.keyboard('{escape}');
|
||||||
|
act(() => {
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(afterButton).toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -8,7 +8,6 @@ import { GrafanaTheme2 } from '@grafana/data';
|
|||||||
import { useStyles2 } from '../../themes/ThemeContext';
|
import { useStyles2 } from '../../themes/ThemeContext';
|
||||||
import { buildTooltipTheme } from '../../utils/tooltipUtils';
|
import { buildTooltipTheme } from '../../utils/tooltipUtils';
|
||||||
import { IconButton } from '../IconButton/IconButton';
|
import { IconButton } from '../IconButton/IconButton';
|
||||||
import { Portal } from '../Portal/Portal';
|
|
||||||
|
|
||||||
import { ToggletipContent } from './types';
|
import { ToggletipContent } from './types';
|
||||||
|
|
||||||
@ -50,27 +49,9 @@ export const Toggletip = React.memo(
|
|||||||
const contentRef = useRef(null);
|
const contentRef = useRef(null);
|
||||||
const [controlledVisible, setControlledVisible] = React.useState(false);
|
const [controlledVisible, setControlledVisible] = React.useState(false);
|
||||||
|
|
||||||
const closeToggletip = useCallback(() => {
|
const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible, update, tooltipRef, triggerRef } =
|
||||||
setControlledVisible(false);
|
usePopperTooltip(
|
||||||
onClose?.();
|
{
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (controlledVisible) {
|
|
||||||
const handleKeyDown = (enterKey: KeyboardEvent) => {
|
|
||||||
if (enterKey.key === 'Escape') {
|
|
||||||
closeToggletip();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}, [controlledVisible, closeToggletip]);
|
|
||||||
|
|
||||||
const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible, update } = usePopperTooltip({
|
|
||||||
visible: controlledVisible,
|
visible: controlledVisible,
|
||||||
placement: placement,
|
placement: placement,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
@ -82,16 +63,47 @@ export const Toggletip = React.memo(
|
|||||||
onClose?.();
|
onClose?.();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
strategy: 'fixed',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeToggletip = useCallback(
|
||||||
|
(event: KeyboardEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
|
setControlledVisible(false);
|
||||||
|
onClose?.();
|
||||||
|
|
||||||
|
if (event.target instanceof Node && tooltipRef?.contains(event.target)) {
|
||||||
|
triggerRef?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose, tooltipRef, triggerRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (controlledVisible) {
|
||||||
|
const handleKeyDown = (enterKey: KeyboardEvent) => {
|
||||||
|
if (enterKey.key === 'Escape') {
|
||||||
|
closeToggletip(enterKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}, [controlledVisible, closeToggletip]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{React.cloneElement(children, {
|
{React.cloneElement(children, {
|
||||||
ref: setTriggerRef,
|
ref: setTriggerRef,
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
|
'aria-expanded': visible,
|
||||||
})}
|
})}
|
||||||
{visible && (
|
{visible && (
|
||||||
<Portal>
|
|
||||||
<div
|
<div
|
||||||
data-testid="toggletip-content"
|
data-testid="toggletip-content"
|
||||||
ref={setTooltipRef}
|
ref={setTooltipRef}
|
||||||
@ -115,7 +127,6 @@ export const Toggletip = React.memo(
|
|||||||
</div>
|
</div>
|
||||||
{Boolean(footer) && <div className={style.footer}>{footer}</div>}
|
{Boolean(footer) && <div className={style.footer}>{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user