Browse Source

SONAR-16703 [894677] Tooltip: Tooltip content is not accessible to screen readers

tags/9.6.0.59041
Wouter Admiraal 1 year ago
parent
commit
e308ae8ff7

+ 8
- 1
server/sonar-web/src/main/js/components/common/DocumentationTooltip.tsx View File

@@ -52,7 +52,14 @@ export default function DocumentationTooltip(props: DocumentationTooltipProps) {
<hr className="big-spacer-top big-spacer-bottom" />

{links.map(({ href, label, inPlace }) => (
<div className="little-spacer-bottom" key={label}>
<div
className="little-spacer-bottom"
key={label}
// a11y: tooltips with interactive content are not supported by screen readers.
// To prevent the screen reader from reading out the links for no reason (as
// they won't be "clickable"), we hide the whole links section.
// See https://sarahmhigley.com/writing/tooltips-in-wcag-21/
aria-hidden={true}>
{inPlace ? (
<Link to={href}>
<span>{label}</span>

+ 3
- 0
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DocumentationTooltip-test.tsx.snap View File

@@ -74,6 +74,7 @@ exports[`renders correctly: with links 1`] = `
className="big-spacer-top big-spacer-bottom"
/>
<div
aria-hidden={true}
className="little-spacer-bottom"
>
<Link
@@ -92,6 +93,7 @@ exports[`renders correctly: with links 1`] = `
</Link>
</div>
<div
aria-hidden={true}
className="little-spacer-bottom"
>
<Link
@@ -106,6 +108,7 @@ exports[`renders correctly: with links 1`] = `
</Link>
</div>
<div
aria-hidden={true}
className="little-spacer-bottom"
>
<Link

+ 48
- 19
server/sonar-web/src/main/js/components/controls/Tooltip.tsx View File

@@ -17,10 +17,11 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { throttle } from 'lodash';
import { throttle, uniqueId } from 'lodash';
import * as React from 'react';
import { createPortal, findDOMNode } from 'react-dom';
import { rawSizes } from '../../app/theme';
import EscKeydownHandler from './EscKeydownHandler';
import ScreenPositionFixer from './ScreenPositionFixer';
import './Tooltip.css';

@@ -65,9 +66,8 @@ function isMeasured(state: State): state is OwnState & Measurements {
}

export default function Tooltip(props: TooltipProps) {
// overlay is a ReactNode, so it can be `undefined` or `null`
// this allows to easily render a tooltip conditionally
// more generaly we avoid rendering empty tooltips
// `overlay` is a ReactNode, so it can be `undefined` or `null`. This allows to easily
// render a tooltip conditionally. More generally, we avoid rendering empty tooltips.
return props.overlay != null && props.overlay !== '' ? (
<TooltipInner {...props} />
) : (
@@ -82,6 +82,7 @@ export class TooltipInner extends React.Component<TooltipProps, State> {
tooltipNode?: HTMLElement | null;
mounted = false;
mouseIn = false;
id: string;

static defaultProps = {
mouseEnterDelay: 0.1
@@ -94,6 +95,7 @@ export class TooltipInner extends React.Component<TooltipProps, State> {
placement: props.placement,
visible: props.visible !== undefined ? props.visible : false
};
this.id = uniqueId('tooltip-');
this.throttledPositionTooltip = throttle(this.positionTooltip, 10);
}

@@ -182,9 +184,9 @@ export class TooltipInner extends React.Component<TooltipProps, State> {
};

positionTooltip = () => {
// `findDOMNode(this)` will search for the DOM node for the current component
// first it will find a React.Fragment (see `render`),
// so it will get the DOM node of the first child, i.e. DOM node of `this.props.children`
// `findDOMNode(this)` will search for the DOM node for the current component.
// First, it will find a React.Fragment (see `render`). It will skip this, and
// it will get the DOM node of the first child, i.e. DOM node of `this.props.children`.
// docs: https://reactjs.org/docs/refs-and-the-dom.html#exposing-dom-refs-to-parent-components

// eslint-disable-next-line react/no-find-dom-node
@@ -217,8 +219,8 @@ export class TooltipInner extends React.Component<TooltipProps, State> {
break;
}

// save width and height (and later set in `render`) to avoid resizing the tooltip element,
// when it's placed close to the window edge
// Save width and height (and later set in `render`) to avoid resizing the tooltip
// element when it's placed close to the window's edge.
this.setState({
left: window.pageXOffset + left,
top: window.pageYOffset + top,
@@ -241,9 +243,9 @@ export class TooltipInner extends React.Component<TooltipProps, State> {

handleMouseEnter = () => {
this.mouseEnterTimeout = window.setTimeout(() => {
// for some reason even after the `this.mouseEnterTimeout` is cleared, it still triggers
// to workaround this issue, check that its value is not `undefined`
// (if it's `undefined`, it means the timer has been reset)
// For some reason, even after the `this.mouseEnterTimeout` is cleared, it still
// triggers. To workaround this issue, check that its value is not `undefined`
// (if it's `undefined`, it means the timer has been reset).
if (
this.mounted &&
this.props.visible === undefined &&
@@ -276,6 +278,20 @@ export class TooltipInner extends React.Component<TooltipProps, State> {
}
};

handleFocus = () => {
this.setState({ visible: true });
if (this.props.onShow) {
this.props.onShow();
}
};

handleBlur = () => {
this.setState({ visible: false });
if (this.props.onHide) {
this.props.onHide();
}
};

handleOverlayMouseEnter = () => {
this.mouseIn = true;
};
@@ -350,7 +366,9 @@ export class TooltipInner extends React.Component<TooltipProps, State> {
onPointerLeave={this.handleOverlayMouseLeave}
ref={this.tooltipNodeRef}
style={style}>
<div className={`${classNameSpace}-inner`}>{this.props.overlay}</div>
<div className={`${classNameSpace}-inner`} id={this.id}>
{this.props.overlay}
</div>
<div
className={`${classNameSpace}-arrow`}
style={
@@ -368,14 +386,25 @@ export class TooltipInner extends React.Component<TooltipProps, State> {
<>
{React.cloneElement(this.props.children, {
onPointerEnter: this.handleMouseEnter,
onPointerLeave: this.handleMouseLeave
onPointerLeave: this.handleMouseLeave,
onFocus: this.handleFocus,
onBlur: this.handleBlur,
tabIndex: 0,
// aria-describedby is the semantically correct property to use, but it's not
// always well supported. As a fallback, we use aria-labelledby as well.
// See https://sarahmhigley.com/writing/tooltips-in-wcag-21/
// See https://css-tricks.com/accessible-svgs/
'aria-describedby': this.id,
'aria-labelledby': this.id
})}
{this.isVisible() && (
<TooltipPortal>
<ScreenPositionFixer ready={isMeasured(this.state)}>
{this.renderActual}
</ScreenPositionFixer>
</TooltipPortal>
<EscKeydownHandler onKeydown={this.handleBlur}>
<TooltipPortal>
<ScreenPositionFixer ready={isMeasured(this.state)}>
{this.renderActual}
</ScreenPositionFixer>
</TooltipPortal>
</EscKeydownHandler>
)}
</>
);

+ 18
- 0
server/sonar-web/src/main/js/components/controls/__tests__/Tooltip-test.tsx View File

@@ -37,6 +37,13 @@ jest.mock('react-dom', () => {
});
});

jest.mock('lodash', () => {
const actual = jest.requireActual('lodash');
return Object.assign({}, actual, {
uniqueId: jest.fn(prefix => `${prefix}1`)
});
});

beforeEach(jest.clearAllMocks);

it('should render', () => {
@@ -67,6 +74,17 @@ it('should open & close', () => {
wrapper.update();
expect(wrapper.find('TooltipPortal').exists()).toBe(false);
expect(onHide).toBeCalled();

onShow.mockReset();
onHide.mockReset();

wrapper.find('#tooltip').simulate('focus');
expect(wrapper.find('TooltipPortal').exists()).toBe(true);
expect(onShow).toBeCalled();

wrapper.find('#tooltip').simulate('blur');
expect(wrapper.find('TooltipPortal').exists()).toBe(false);
expect(onHide).toBeCalled();
});

it('should not open when pointer goes away quickly', () => {

+ 5
- 0
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ActionsDropdown-test.tsx.snap View File

@@ -120,9 +120,14 @@ exports[`ActionsDropdownItem should render correctly copy item 1`] = `
visible={false}
>
<li
aria-describedby="tooltip-1"
aria-labelledby="tooltip-1"
data-clipboard-text="my content to copy to clipboard"
onBlur={[Function]}
onFocus={[Function]}
onPointerEnter={[Function]}
onPointerLeave={[Function]}
tabIndex={0}
>
<a
className="foo"

+ 21
- 7
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tooltip-test.tsx.snap View File

@@ -15,9 +15,14 @@ exports[`should not render empty tooltips 2`] = `
exports[`should render 1`] = `
<Fragment>
<div
aria-describedby="tooltip-1"
aria-labelledby="tooltip-1"
id="tooltip"
onBlur={[Function]}
onFocus={[Function]}
onPointerEnter={[Function]}
onPointerLeave={[Function]}
tabIndex={0}
/>
</Fragment>
`;
@@ -25,16 +30,25 @@ exports[`should render 1`] = `
exports[`should render 2`] = `
<Fragment>
<div
aria-describedby="tooltip-1"
aria-labelledby="tooltip-1"
id="tooltip"
onBlur={[Function]}
onFocus={[Function]}
onPointerEnter={[Function]}
onPointerLeave={[Function]}
tabIndex={0}
/>
<TooltipPortal>
<ScreenPositionFixer
ready={false}
>
<Component />
</ScreenPositionFixer>
</TooltipPortal>
<EscKeydownHandler
onKeydown={[Function]}
>
<TooltipPortal>
<ScreenPositionFixer
ready={false}
>
<Component />
</ScreenPositionFixer>
</TooltipPortal>
</EscKeydownHandler>
</Fragment>
`;

Loading…
Cancel
Save