aboutsummaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorWouter Admiraal <wouter.admiraal@sonarsource.com>2022-07-26 14:05:13 +0200
committersonartech <sonartech@sonarsource.com>2022-07-28 20:02:55 +0000
commite308ae8ff7506c3626bb8f19bf114bc3bfe94845 (patch)
tree616a784e10302eaee2c482c5bd9219e7338ae0dc /server
parent20ff6984cda389ef9c01005e5f352fe95dad4e0a (diff)
downloadsonarqube-e308ae8ff7506c3626bb8f19bf114bc3bfe94845.tar.gz
sonarqube-e308ae8ff7506c3626bb8f19bf114bc3bfe94845.zip
SONAR-16703 [894677] Tooltip: Tooltip content is not accessible to screen readers
Diffstat (limited to 'server')
-rw-r--r--server/sonar-web/src/main/js/components/common/DocumentationTooltip.tsx9
-rw-r--r--server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DocumentationTooltip-test.tsx.snap3
-rw-r--r--server/sonar-web/src/main/js/components/controls/Tooltip.tsx67
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/Tooltip-test.tsx18
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ActionsDropdown-test.tsx.snap5
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tooltip-test.tsx.snap28
6 files changed, 103 insertions, 27 deletions
diff --git a/server/sonar-web/src/main/js/components/common/DocumentationTooltip.tsx b/server/sonar-web/src/main/js/components/common/DocumentationTooltip.tsx
index e2150e74794..3e582b2aed0 100644
--- a/server/sonar-web/src/main/js/components/common/DocumentationTooltip.tsx
+++ b/server/sonar-web/src/main/js/components/common/DocumentationTooltip.tsx
@@ -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>
diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DocumentationTooltip-test.tsx.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DocumentationTooltip-test.tsx.snap
index 3755fb97cf0..949ca3317f0 100644
--- a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DocumentationTooltip-test.tsx.snap
+++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DocumentationTooltip-test.tsx.snap
@@ -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
diff --git a/server/sonar-web/src/main/js/components/controls/Tooltip.tsx b/server/sonar-web/src/main/js/components/controls/Tooltip.tsx
index e52cd274a8d..2b7b07f17b4 100644
--- a/server/sonar-web/src/main/js/components/controls/Tooltip.tsx
+++ b/server/sonar-web/src/main/js/components/controls/Tooltip.tsx
@@ -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>
)}
</>
);
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/Tooltip-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/Tooltip-test.tsx
index bd79b3c3ca8..e02b980294e 100644
--- a/server/sonar-web/src/main/js/components/controls/__tests__/Tooltip-test.tsx
+++ b/server/sonar-web/src/main/js/components/controls/__tests__/Tooltip-test.tsx
@@ -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', () => {
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ActionsDropdown-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ActionsDropdown-test.tsx.snap
index dbb20219cbe..37677005279 100644
--- a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ActionsDropdown-test.tsx.snap
+++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ActionsDropdown-test.tsx.snap
@@ -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"
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tooltip-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tooltip-test.tsx.snap
index 50bceeb148b..a1215a300a6 100644
--- a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tooltip-test.tsx.snap
+++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tooltip-test.tsx.snap
@@ -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>
`;