Make tooltips keyboard accessible (#7281)
* show tooltips on hover in eventtile Signed-off-by: Kerry Archibald <kerrya@element.io> * use tooltip props pass thru * use tooltiptarget in InfoTooltip Signed-off-by: Kerry Archibald <kerrya@element.io> * use target in TestWithTooltip Signed-off-by: Kerry Archibald <kerrya@element.io> * tsc fixes Signed-off-by: Kerry Archibald <kerrya@element.io> * test tooltip target Signed-off-by: Kerry Archibald <kerrya@element.io> * lint fix Signed-off-by: Kerry Archibald <kerrya@element.io> * rename tooltip handlers Signed-off-by: Kerry Archibald <kerrya@element.io> * update copyright to 2021 Signed-off-by: Kerry Archibald <kerrya@element.io>pull/21833/head
parent
4712ae49b2
commit
0c850b2f13
|
@ -13,6 +13,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
.mx_TextWithTooltip_target {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.mx_TextWithTooltip_tooltip {
|
||||
display: none;
|
||||
|
|
|
@ -52,14 +52,14 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
|
|||
}
|
||||
}
|
||||
|
||||
onMouseOver = () => {
|
||||
showTooltip = () => {
|
||||
if (this.props.forceHide) return;
|
||||
this.setState({
|
||||
hover: true,
|
||||
});
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
hideTooltip = () => {
|
||||
this.setState({
|
||||
hover: false,
|
||||
});
|
||||
|
@ -78,8 +78,10 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
|
|||
return (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onMouseOver={this.showTooltip}
|
||||
onMouseLeave={this.hideTooltip}
|
||||
onFocus={this.showTooltip}
|
||||
onBlur={this.hideTooltip}
|
||||
aria-label={title}
|
||||
>
|
||||
{ children }
|
||||
|
|
|
@ -58,13 +58,17 @@ export default class ActionButton extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private onMouseEnter = (): void => {
|
||||
if (this.props.tooltip) this.setState({ showTooltip: true });
|
||||
this.showTooltip();
|
||||
if (this.props.mouseOverAction) {
|
||||
dis.dispatch({ action: this.props.mouseOverAction });
|
||||
}
|
||||
};
|
||||
|
||||
private onMouseLeave = (): void => {
|
||||
private showTooltip = (): void => {
|
||||
if (this.props.tooltip) this.setState({ showTooltip: true });
|
||||
};
|
||||
|
||||
private hideTooltip = (): void => {
|
||||
this.setState({ showTooltip: false });
|
||||
};
|
||||
|
||||
|
@ -88,7 +92,9 @@ export default class ActionButton extends React.Component<IProps, IState> {
|
|||
className={classNames.join(" ")}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onMouseLeave={this.hideTooltip}
|
||||
onFocus={this.showTooltip}
|
||||
onBlur={this.hideTooltip}
|
||||
aria-label={this.props.label}
|
||||
>
|
||||
{ icon }
|
||||
|
|
|
@ -18,9 +18,10 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Tooltip, { Alignment } from './Tooltip';
|
||||
import { Alignment } from './Tooltip';
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { TooltipTarget } from './TooltipTarget';
|
||||
|
||||
export enum InfoTooltipKind {
|
||||
Info = "info",
|
||||
|
@ -34,31 +35,12 @@ interface ITooltipProps {
|
|||
kind?: InfoTooltipKind;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.InfoTooltip")
|
||||
export default class InfoTooltip extends React.PureComponent<ITooltipProps, IState> {
|
||||
export default class InfoTooltip extends React.PureComponent<ITooltipProps> {
|
||||
constructor(props: ITooltipProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hover: false,
|
||||
};
|
||||
}
|
||||
|
||||
onMouseOver = () => {
|
||||
this.setState({
|
||||
hover: true,
|
||||
});
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this.setState({
|
||||
hover: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { tooltip, children, tooltipClassName, className, kind } = this.props;
|
||||
const title = _t("Information");
|
||||
|
@ -68,22 +50,16 @@ export default class InfoTooltip extends React.PureComponent<ITooltipProps, ISta
|
|||
);
|
||||
|
||||
// Tooltip are forced on the right for a more natural feel to them on info icons
|
||||
const tip = this.state.hover ? <Tooltip
|
||||
className="mx_InfoTooltip_container"
|
||||
tooltipClassName={classNames("mx_InfoTooltip_tooltip", tooltipClassName)}
|
||||
label={tooltip || title}
|
||||
alignment={Alignment.Right}
|
||||
/> : <div />;
|
||||
return (
|
||||
<div
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
className={classNames("mx_InfoTooltip", className)}
|
||||
<TooltipTarget tooltipTargetClassName={classNames("mx_InfoTooltip", className)}
|
||||
className="mx_InfoTooltip_container"
|
||||
tooltipClassName={classNames("mx_InfoTooltip_tooltip", tooltipClassName)}
|
||||
label={tooltip || title}
|
||||
alignment={Alignment.Right}
|
||||
>
|
||||
<span className={classNames("mx_InfoTooltip_icon", iconClassName)} aria-label={title} />
|
||||
{ children }
|
||||
{ tip }
|
||||
</div>
|
||||
</TooltipTarget>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,8 +15,9 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { TooltipTarget } from './TooltipTarget';
|
||||
|
||||
interface IProps {
|
||||
class?: string;
|
||||
|
@ -26,41 +27,27 @@ interface IProps {
|
|||
onClick?: (ev?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.TextWithTooltip")
|
||||
export default class TextWithTooltip extends React.Component<IProps, IState> {
|
||||
export default class TextWithTooltip extends React.Component<IProps> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hover: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onMouseOver = (): void => {
|
||||
this.setState({ hover: true });
|
||||
};
|
||||
|
||||
private onMouseLeave = (): void => {
|
||||
this.setState({ hover: false });
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const { class: className, children, tooltip, tooltipClass, tooltipProps, ...props } = this.props;
|
||||
|
||||
return (
|
||||
<span {...props} onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} onClick={this.props.onClick} className={className}>
|
||||
<TooltipTarget
|
||||
onClick={this.props.onClick}
|
||||
tooltipTargetClassName={classNames("mx_TextWithTooltip_target", className)}
|
||||
{...tooltipProps}
|
||||
label={tooltip}
|
||||
tooltipClassName={tooltipClass}
|
||||
className="mx_TextWithTooltip_tooltip"
|
||||
{...props}
|
||||
>
|
||||
{ children }
|
||||
{ this.state.hover && <Tooltip
|
||||
{...tooltipProps}
|
||||
label={tooltip}
|
||||
tooltipClassName={tooltipClass}
|
||||
className="mx_TextWithTooltip_tooltip"
|
||||
/> }
|
||||
</span>
|
||||
</TooltipTarget>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,7 +33,7 @@ export enum Alignment {
|
|||
Bottom, // Centered
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
export interface ITooltipProps {
|
||||
// Class applied to the element used to position the tooltip
|
||||
className?: string;
|
||||
// Class applied to the tooltip itself
|
||||
|
@ -46,10 +46,13 @@ interface IProps {
|
|||
label: React.ReactNode;
|
||||
alignment?: Alignment; // defaults to Natural
|
||||
yOffset?: number;
|
||||
// id describing tooltip
|
||||
// used to associate tooltip with target for a11y
|
||||
id?: string;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.Tooltip")
|
||||
export default class Tooltip extends React.Component<IProps> {
|
||||
export default class Tooltip extends React.Component<ITooltipProps> {
|
||||
private tooltipContainer: HTMLElement;
|
||||
private tooltip: void | Element | Component<Element, any, any>;
|
||||
private parent: Element;
|
||||
|
|
|
@ -17,48 +17,28 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Tooltip from './Tooltip';
|
||||
import { TooltipTarget } from './TooltipTarget';
|
||||
|
||||
interface IProps {
|
||||
helpText: React.ReactNode | string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.TooltipButton")
|
||||
export default class TooltipButton extends React.Component<IProps, IState> {
|
||||
export default class TooltipButton extends React.Component<IProps> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hover: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onMouseOver = () => {
|
||||
this.setState({
|
||||
hover: true,
|
||||
});
|
||||
};
|
||||
|
||||
private onMouseLeave = () => {
|
||||
this.setState({
|
||||
hover: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const tip = this.state.hover ? <Tooltip
|
||||
className="mx_TooltipButton_container"
|
||||
tooltipClassName="mx_TooltipButton_helpText"
|
||||
label={this.props.helpText}
|
||||
/> : <div />;
|
||||
return (
|
||||
<div className="mx_TooltipButton" onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave}>
|
||||
<TooltipTarget
|
||||
className="mx_TooltipButton_container"
|
||||
tooltipClassName="mx_TooltipButton_helpText"
|
||||
tooltipTargetClassName="mx_TooltipButton"
|
||||
label={this.props.helpText}
|
||||
>
|
||||
?
|
||||
{ tip }
|
||||
</div>
|
||||
</TooltipTarget>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState, HTMLAttributes } from 'react';
|
||||
import Tooltip, { ITooltipProps } from './Tooltip';
|
||||
|
||||
interface IProps extends HTMLAttributes<HTMLSpanElement>, Omit<ITooltipProps, 'visible'> {
|
||||
tooltipTargetClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic tooltip target element that handles tooltip visibility state
|
||||
* and displays children
|
||||
*/
|
||||
export const TooltipTarget: React.FC<IProps> = ({
|
||||
children,
|
||||
tooltipTargetClassName,
|
||||
// tooltip pass through props
|
||||
className,
|
||||
id,
|
||||
label,
|
||||
alignment,
|
||||
yOffset,
|
||||
tooltipClassName,
|
||||
...rest
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const show = () => setIsVisible(true);
|
||||
const hide = () => setIsVisible(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
aria-describedby={id}
|
||||
className={tooltipTargetClassName}
|
||||
onMouseOver={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
{...rest}
|
||||
>
|
||||
{ children }
|
||||
<Tooltip
|
||||
id={id}
|
||||
className={className}
|
||||
tooltipClassName={tooltipClassName}
|
||||
label={label}
|
||||
yOffset={yOffset}
|
||||
alignment={alignment}
|
||||
visible={isVisible}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1203,7 +1203,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
_t(
|
||||
'<requestLink>Re-request encryption keys</requestLink> from your other sessions.',
|
||||
{},
|
||||
{ 'requestLink': (sub) => <a onClick={this.onRequestKeysClick}>{ sub }</a> },
|
||||
{ 'requestLink': (sub) => <a tabIndex={0} onClick={this.onRequestKeysClick}>{ sub }</a> },
|
||||
);
|
||||
|
||||
const keyRequestInfo = isEncryptionFailure && !isRedacted ?
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
// skinned-sdk should be the first import in most tests
|
||||
import '../../../skinned-sdk';
|
||||
import React from "react";
|
||||
import {
|
||||
renderIntoDocument,
|
||||
Simulate,
|
||||
} from 'react-dom/test-utils';
|
||||
import { act } from "react-dom/test-utils";
|
||||
|
||||
import { Alignment } from '../../../../src/components/views/elements/Tooltip';
|
||||
import { TooltipTarget } from "../../../../src/components/views/elements/TooltipTarget";
|
||||
|
||||
describe('<TooltipTarget />', () => {
|
||||
const defaultProps = {
|
||||
"tooltipTargetClassName": 'test tooltipTargetClassName',
|
||||
"className": 'test className',
|
||||
"tooltipClassName": 'test tooltipClassName',
|
||||
"label": 'test label',
|
||||
"yOffset": 1,
|
||||
"alignment": Alignment.Left,
|
||||
"id": 'test id',
|
||||
'data-test-id': 'test',
|
||||
};
|
||||
|
||||
const getComponent = (props = {}) => {
|
||||
const wrapper = renderIntoDocument<HTMLSpanElement>(
|
||||
// wrap in element so renderIntoDocument can render functional component
|
||||
<span>
|
||||
<TooltipTarget {...defaultProps} {...props}>
|
||||
<span>child</span>
|
||||
</TooltipTarget>
|
||||
</span>,
|
||||
) as HTMLSpanElement;
|
||||
return wrapper.querySelector('[data-test-id=test]');
|
||||
};
|
||||
|
||||
const getVisibleTooltip = () => document.querySelector('.mx_Tooltip.mx_Tooltip_visible');
|
||||
|
||||
afterEach(() => {
|
||||
// clean up visible tooltips
|
||||
const tooltipWrapper = document.querySelector('.mx_Tooltip_wrapper');
|
||||
document.body.removeChild(tooltipWrapper);
|
||||
});
|
||||
|
||||
it('renders container', () => {
|
||||
const component = getComponent();
|
||||
expect(component).toMatchSnapshot();
|
||||
expect(getVisibleTooltip()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('displays tooltip on mouseover', () => {
|
||||
const wrapper = getComponent();
|
||||
act(() => {
|
||||
Simulate.mouseOver(wrapper);
|
||||
});
|
||||
expect(getVisibleTooltip()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('hides tooltip on mouseleave', () => {
|
||||
const wrapper = getComponent();
|
||||
act(() => {
|
||||
Simulate.mouseOver(wrapper);
|
||||
});
|
||||
expect(getVisibleTooltip()).toBeTruthy();
|
||||
act(() => {
|
||||
Simulate.mouseLeave(wrapper);
|
||||
});
|
||||
expect(getVisibleTooltip()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('displays tooltip on focus', () => {
|
||||
const wrapper = getComponent();
|
||||
act(() => {
|
||||
Simulate.focus(wrapper);
|
||||
});
|
||||
expect(getVisibleTooltip()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides tooltip on blur', async () => {
|
||||
const wrapper = getComponent();
|
||||
act(() => {
|
||||
Simulate.focus(wrapper);
|
||||
});
|
||||
expect(getVisibleTooltip()).toBeTruthy();
|
||||
await act(async () => {
|
||||
await Simulate.blur(wrapper);
|
||||
});
|
||||
expect(getVisibleTooltip()).toBeFalsy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<TooltipTarget /> displays tooltip on mouseover 1`] = `
|
||||
<div
|
||||
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
|
||||
style="right: 1008px; top: -26px; display: block;"
|
||||
>
|
||||
<div
|
||||
class="mx_Tooltip_chevron"
|
||||
/>
|
||||
test label
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<TooltipTarget /> renders container 1`] = `
|
||||
<div
|
||||
aria-describedby="test id"
|
||||
class="test tooltipTargetClassName"
|
||||
data-test-id="test"
|
||||
tabindex="0"
|
||||
>
|
||||
<span>
|
||||
child
|
||||
</span>
|
||||
<div
|
||||
class="test className"
|
||||
/>
|
||||
</div>
|
||||
`;
|
Loading…
Reference in New Issue