diff options
author | Wouter Admiraal <wouter.admiraal@sonarsource.com> | 2022-07-26 14:05:13 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2022-07-28 20:02:55 +0000 |
commit | e308ae8ff7506c3626bb8f19bf114bc3bfe94845 (patch) | |
tree | 616a784e10302eaee2c482c5bd9219e7338ae0dc /server | |
parent | 20ff6984cda389ef9c01005e5f352fe95dad4e0a (diff) | |
download | sonarqube-e308ae8ff7506c3626bb8f19bf114bc3bfe94845.tar.gz sonarqube-e308ae8ff7506c3626bb8f19bf114bc3bfe94845.zip |
SONAR-16703 [894677] Tooltip: Tooltip content is not accessible to screen readers
Diffstat (limited to 'server')
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> `; |