diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 4a4ac9e56c3..15fc2e075ef 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -632,13 +632,13 @@ export function topicToHtml( emojiBodyElements = formatEmojis(topic, false); } - return isFormattedTopic ? - : + /> + : { emojiBodyElements || topic } ; } diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index 3b7e6c96701..ae1aff26def 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -52,8 +52,10 @@ export interface ITooltipProps { maxParentWidth?: number; } -export default class Tooltip extends React.Component { - private tooltipContainer: HTMLElement; +type State = Partial>; + +export default class Tooltip extends React.PureComponent { + private static container: HTMLElement; private parent: Element; // XXX: This is because some components (Field) are unable to `import` the Tooltip class, @@ -65,37 +67,47 @@ export default class Tooltip extends React.Component { alignment: Alignment.Natural, }; - // Create a wrapper for the tooltip outside the parent and attach it to the body element + constructor(props) { + super(props); + + this.state = {}; + + // Create a wrapper for the tooltips and attach it to the body element + if (!Tooltip.container) { + Tooltip.container = document.createElement("div"); + Tooltip.container.className = "mx_Tooltip_wrapper"; + document.body.appendChild(Tooltip.container); + } + } + public componentDidMount() { - this.tooltipContainer = document.createElement("div"); - this.tooltipContainer.className = "mx_Tooltip_wrapper"; - document.body.appendChild(this.tooltipContainer); - window.addEventListener('scroll', this.renderTooltip, { + window.addEventListener('scroll', this.updatePosition, { passive: true, capture: true, }); this.parent = ReactDOM.findDOMNode(this).parentNode as Element; - this.renderTooltip(); + this.updatePosition(); } public componentDidUpdate() { - this.renderTooltip(); + this.updatePosition(); } // Remove the wrapper element, as the tooltip has finished using it public componentWillUnmount() { - ReactDOM.unmountComponentAtNode(this.tooltipContainer); - document.body.removeChild(this.tooltipContainer); - window.removeEventListener('scroll', this.renderTooltip, { + window.removeEventListener('scroll', this.updatePosition, { capture: true, }); } // Add the parent's position to the tooltips, so it's correctly // positioned, also taking into account any window zoom - private updatePosition(style: CSSProperties) { + private updatePosition = (): void => { + // When the tooltip is hidden, no need to thrash the DOM with `style` attribute updates (performance) + if (!this.props.visible) return; + const parentBox = this.parent.getBoundingClientRect(); const width = UIStore.instance.windowWidth; const spacing = 6; @@ -112,6 +124,7 @@ export default class Tooltip extends React.Component { parentBox.left - window.scrollX + (parentWidth / 2) ); + const style: State = {}; switch (this.props.alignment) { case Alignment.Natural: if (parentBox.right > width / 2) { @@ -153,25 +166,20 @@ export default class Tooltip extends React.Component { break; } - return style; - } - - private renderTooltip = () => { - let style: CSSProperties = {}; - // When the tooltip is hidden, no need to thrash the DOM with `style` - // attribute updates (performance) - if (this.props.visible) { - style = this.updatePosition({}); - } - // Hide the entire container when not visible. This prevents flashing of the tooltip - // if it is not meant to be visible on first mount. - style.display = this.props.visible ? "block" : "none"; + this.setState(style); + }; + public render() { const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName, { "mx_Tooltip_visible": this.props.visible, "mx_Tooltip_invisible": !this.props.visible, }); + const style = { ...this.state }; + // Hide the entire container when not visible. + // This prevents flashing of the tooltip if it is not meant to be visible on first mount. + style.display = this.props.visible ? "block" : "none"; + const tooltip = (
@@ -179,14 +187,10 @@ export default class Tooltip extends React.Component {
); - // Render the tooltip manually, as we wish it not to be rendered within the parent - ReactDOM.render(tooltip, this.tooltipContainer); - }; - - public render() { - // Render a placeholder return ( -
+
+ { ReactDOM.createPortal(tooltip, Tooltip.container) } +
); } } diff --git a/test/components/views/elements/TooltipTarget-test.tsx b/test/components/views/elements/TooltipTarget-test.tsx index a57cdf7187a..56b0b37f62a 100644 --- a/test/components/views/elements/TooltipTarget-test.tsx +++ b/test/components/views/elements/TooltipTarget-test.tsx @@ -35,6 +35,14 @@ describe('', () => { 'data-test-id': 'test', }; + afterEach(() => { + // clean up renderer tooltips + const wrapper = document.querySelector('.mx_Tooltip_wrapper'); + while (wrapper?.firstChild) { + wrapper.removeChild(wrapper.lastChild); + } + }); + const getComponent = (props = {}) => { const wrapper = renderIntoDocument( // wrap in element so renderIntoDocument can render functional component @@ -49,31 +57,20 @@ describe('', () => { const getVisibleTooltip = () => document.querySelector('.mx_Tooltip.mx_Tooltip_visible'); - afterEach(() => { - // clean up visible tooltips - const tooltipWrapper = document.querySelector('.mx_Tooltip_wrapper'); - if (tooltipWrapper) { - document.body.removeChild(tooltipWrapper); - } - }); - it('renders container', () => { const component = getComponent(); expect(component).toMatchSnapshot(); expect(getVisibleTooltip()).toBeFalsy(); }); - for (const alignment in Alignment) { - if (isNaN(Number(alignment))) { - it(`displays ${alignment} aligned tooltip on mouseover`, () => { - const wrapper = getComponent({ alignment: Alignment[alignment] }); - act(() => { - Simulate.mouseOver(wrapper); - }); - expect(getVisibleTooltip()).toMatchSnapshot(); - }); - } - } + const alignmentKeys = Object.keys(Alignment).filter((o: any) => isNaN(o)); + it.each(alignmentKeys)("displays %s aligned tooltip on mouseover", async (alignment) => { + const wrapper = getComponent({ alignment: Alignment[alignment] }); + act(() => { + Simulate.mouseOver(wrapper); + }); + expect(getVisibleTooltip()).toMatchSnapshot(); + }); it('hides tooltip on mouseleave', () => { const wrapper = getComponent(); @@ -101,8 +98,8 @@ describe('', () => { Simulate.focus(wrapper); }); expect(getVisibleTooltip()).toBeTruthy(); - await act(async () => { - await Simulate.blur(wrapper); + act(() => { + 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 index bdaab7071bb..d8d2b69e595 100644 --- a/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap @@ -3,7 +3,7 @@ exports[` displays Bottom aligned tooltip on mouseover 1`] = `
displays Bottom aligned tooltip on mouseover 1`] = ` exports[` displays InnerBottom aligned tooltip on mouseover 1`] = `
displays InnerBottom aligned tooltip on mouseover 1`] exports[` displays Left aligned tooltip on mouseover 1`] = `
displays Left aligned tooltip on mouseover 1`] = ` exports[` displays Natural aligned tooltip on mouseover 1`] = `
displays Natural aligned tooltip on mouseover 1`] = ` exports[` displays Right aligned tooltip on mouseover 1`] = `
displays Right aligned tooltip on mouseover 1`] = ` exports[` displays Top aligned tooltip on mouseover 1`] = `
displays Top aligned tooltip on mouseover 1`] = ` exports[` displays TopRight aligned tooltip on mouseover 1`] = `