diff --git a/res/css/views/elements/_TextWithTooltip.scss b/res/css/views/elements/_TextWithTooltip.scss index a7f9cb74830..4a3702d6c16 100644 --- a/res/css/views/elements/_TextWithTooltip.scss +++ b/res/css/views/elements/_TextWithTooltip.scss @@ -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; diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index e239028ab81..7f40662efe4 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -52,14 +52,14 @@ export default class AccessibleTooltipButton extends React.PureComponent { + 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 { children } diff --git a/src/components/views/elements/ActionButton.tsx b/src/components/views/elements/ActionButton.tsx index 390e84be777..178aca8ca92 100644 --- a/src/components/views/elements/ActionButton.tsx +++ b/src/components/views/elements/ActionButton.tsx @@ -58,13 +58,17 @@ export default class ActionButton extends React.Component { }; 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 { 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 } diff --git a/src/components/views/elements/InfoTooltip.tsx b/src/components/views/elements/InfoTooltip.tsx index 123e1189655..a323ce035d0 100644 --- a/src/components/views/elements/InfoTooltip.tsx +++ b/src/components/views/elements/InfoTooltip.tsx @@ -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 { +export default class InfoTooltip extends React.PureComponent { 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 :
; return ( -
{ children } - { tip } -
+ ); } } diff --git a/src/components/views/elements/TextWithTooltip.tsx b/src/components/views/elements/TextWithTooltip.tsx index b7c24771588..121da9349bf 100644 --- a/src/components/views/elements/TextWithTooltip.tsx +++ b/src/components/views/elements/TextWithTooltip.tsx @@ -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 { +export default class TextWithTooltip extends React.Component { 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 ( - + { children } - { this.state.hover && } - + ); } } diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index c335684c057..199e107ca57 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -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 { +export default class Tooltip extends React.Component { private tooltipContainer: HTMLElement; private tooltip: void | Element | Component; private parent: Element; diff --git a/src/components/views/elements/TooltipButton.tsx b/src/components/views/elements/TooltipButton.tsx index 26e46c7da86..52477695163 100644 --- a/src/components/views/elements/TooltipButton.tsx +++ b/src/components/views/elements/TooltipButton.tsx @@ -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 { +export default class TooltipButton extends React.Component { 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 ? :
; return ( -
+ ? - { tip } -
+ ); } } diff --git a/src/components/views/elements/TooltipTarget.tsx b/src/components/views/elements/TooltipTarget.tsx new file mode 100644 index 00000000000..88ce02b92ce --- /dev/null +++ b/src/components/views/elements/TooltipTarget.tsx @@ -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, Omit { + tooltipTargetClassName?: string; +} + +/** + * Generic tooltip target element that handles tooltip visibility state + * and displays children + */ +export const TooltipTarget: React.FC = ({ + 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 ( +
+ { children } + +
+ ); +}; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 8cf59a2d5d0..73b24be6bc8 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -1202,7 +1202,7 @@ export default class EventTile extends React.Component { _t( 'Re-request encryption keys from your other sessions.', {}, - { 'requestLink': (sub) => { sub } }, + { 'requestLink': (sub) => { sub } }, ); const keyRequestInfo = isEncryptionFailure && !isRedacted ? diff --git a/test/components/views/elements/TooltipTarget-test.tsx b/test/components/views/elements/TooltipTarget-test.tsx new file mode 100644 index 00000000000..fe318e38ec9 --- /dev/null +++ b/test/components/views/elements/TooltipTarget-test.tsx @@ -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('', () => { + 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( + // wrap in element so renderIntoDocument can render functional component + + + child + + , + ) 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(); + }); +}); diff --git a/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap b/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap new file mode 100644 index 00000000000..cdb12e5af41 --- /dev/null +++ b/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` displays tooltip on mouseover 1`] = ` +
+
+ test label +
+`; + +exports[` renders container 1`] = ` +
+ + child + +
+
+`;