[MM-62381] Move LinkTooltip from Popper to Floating-UI (#29727)

Этот коммит содержится в:
M-ZubairAhmed
2025-01-09 16:18:23 +05:30
коммит произвёл GitHub
родитель a3b2ecec19
Коммит db3b72e46c
10 изменённых файлов: 156 добавлений и 323 удалений

Просмотреть файл

@@ -39,23 +39,20 @@ describe('Link tooltips', () => {
cy.apiInitSetup({loginAfter: true}).then(({team, channel}) => {
cy.visit(`/${team.name}/channels/${channel.name}`);
});
cy.postMessage('www.test.com');
});
it('MM-T3422 fade in and out with an animation', () => {
cy.get('a[href*="www.test.com"] span').as('link');
cy.contains('This is a custom tooltip from the Demo Plugin').parents('.tooltip-container').as('tooltip-container');
const url = 'www.test.com';
cy.postMessage(url);
cy.uiWaitUntilMessagePostedIncludes(url);
// # Mouse over the link
cy.get('@link').trigger('mouseover');
// # Hover over the plugin link
cy.findByText(url).should('exist').focus();
// * Check tooltip has appeared
cy.get('@tooltip-container').should('have.class', 'visible');
cy.findByText('This is a custom tooltip from the Demo Plugin').should('be.visible');
// # Mouse out the link
cy.get('@link').trigger('mouseout');
// * Check tooltip has disappeared
cy.get('@tooltip-container').should('not.have.class', 'visible');
// # Close the tooltip
cy.get('body').type('{esc}');
});
});

Просмотреть файл

@@ -1,80 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/link_tooltip/link_tooltip should match snapshot 1`] = `
<Fragment>
<div
className="tooltip-container"
style={
Object {
"alignItems": "center",
"display": "flex",
"flexDirection": "column",
"left": -1000,
"position": "absolute",
"top": -1000,
"zIndex": 1070,
}
}
>
<Connect(Pluggable)
href="www.test.com"
pluggableName="LinkTooltip"
show={false}
/>
</div>
<span
data-channel-mention="somechannel"
data-hashtag="#somehashtag"
data-link="somelink"
onMouseLeave={[Function]}
onMouseOver={[Function]}
>
test title
</span>
</Fragment>
`;
exports[`components/link_tooltip/link_tooltip should match snapshot with uncommon link structure 1`] = `
<Fragment>
<div
className="tooltip-container"
style={
Object {
"alignItems": "center",
"display": "flex",
"flexDirection": "column",
"left": -1000,
"position": "absolute",
"top": -1000,
"zIndex": 1070,
}
}
>
<Connect(Pluggable)
href="https://www.google.com"
pluggableName="LinkTooltip"
show={false}
/>
</div>
<span
onMouseLeave={[Function]}
onMouseOver={[Function]}
>
<span
className="codespan__pre-wrap"
>
<code>
foo
</code>
</span>
and
<span
className="codespan__pre-wrap"
>
<code>
bar
</code>
</span>
</span>
</Fragment>
`;

Просмотреть файл

@@ -1,12 +0,0 @@
@use 'utils/variables';
.tooltip-container {
opacity: 0;
transition: opacity variables.$transition-quick ease-in;
visibility: hidden;
&.visible {
opacity: 1;
visibility: visible;
}
}

Просмотреть файл

@@ -1,52 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import type {ReactPortal} from 'react';
import ReactDOM from 'react-dom';
import LinkTooltip from 'components/link_tooltip/link_tooltip';
describe('components/link_tooltip/link_tooltip', () => {
test('should match snapshot', () => {
ReactDOM.createPortal = (node) => node as ReactPortal;
const wrapper = shallow<LinkTooltip>(
<LinkTooltip
href={'www.test.com'}
attributes={{
class: 'mention-highlight',
'data-hashtag': '#somehashtag',
'data-link': 'somelink',
'data-channel-mention': 'somechannel',
}}
>
{'test title'}
</LinkTooltip>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('span').text()).toBe('test title');
});
test('should match snapshot with uncommon link structure', () => {
ReactDOM.createPortal = (node) => node as ReactPortal;
const wrapper = shallow<LinkTooltip>(
<LinkTooltip
href={'https://www.google.com'}
attributes={{}}
>
<span className='codespan__pre-wrap'>
<code>{'foo'}</code>
</span>
{' and '}
<span className='codespan__pre-wrap'>
<code>{'bar'}</code>
</span>
</LinkTooltip>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('span').at(0).text()).toBe('foo and bar');
});
});

Просмотреть файл

@@ -1,138 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import Popper from 'popper.js';
import React from 'react';
import type {RefObject, CSSProperties} from 'react';
import ReactDOM from 'react-dom';
import Pluggable from 'plugins/pluggable';
import {Constants} from 'utils/constants';
import './link_tooltip.scss';
const tooltipContainerStyles: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
zIndex: 1070,
position: 'absolute',
top: -1000,
left: -1000,
};
type Props = {
href: string;
attributes: {[attribute: string]: string};
children?: React.ReactNode;
}
type State = {
show: boolean;
}
export default class LinkTooltip extends React.PureComponent<Props, State> {
private tooltipContainerRef: RefObject<HTMLDivElement>;
private hideTimeout: number;
private showTimeout: number;
private popper?: Popper;
public constructor(props: Props) {
super(props);
this.tooltipContainerRef = React.createRef();
this.showTimeout = -1;
this.hideTimeout = -1;
this.state = {
show: false,
};
}
public showTooltip = (e: React.MouseEvent<HTMLSpanElement>): void => {
//clear the hideTimeout in the case when the cursor is moved from a tooltipContainer child to the link
window.clearTimeout(this.hideTimeout);
if (!this.state.show) {
const target = e.currentTarget;
const tooltipContainer = this.tooltipContainerRef.current;
//clear the old this.showTimeout if there is any before overriding
window.clearTimeout(this.showTimeout);
this.showTimeout = window.setTimeout(() => {
this.setState({show: true});
if (!tooltipContainer) {
return;
}
const addChildEventListeners = (node: Node) => {
node.addEventListener('mouseover', () => clearTimeout(this.hideTimeout));
(node as HTMLElement).addEventListener('mouseleave', (event) => {
if (event.relatedTarget !== null) {
this.hideTooltip();
}
});
};
tooltipContainer.childNodes.forEach(addChildEventListeners);
this.popper = new Popper(target, tooltipContainer, {
placement: 'bottom',
modifiers: {
preventOverflow: {enabled: false},
hide: {enabled: false},
},
});
}, Constants.OVERLAY_TIME_DELAY);
}
};
public hideTooltip = (): void => {
//clear the old this.hideTimeout if there is any before overriding
window.clearTimeout(this.hideTimeout);
this.hideTimeout = window.setTimeout(() => {
this.setState({show: false});
//prevent executing the showTimeout after the hideTooltip
clearTimeout(this.showTimeout);
}, Constants.OVERLAY_TIME_DELAY_SMALL);
};
public render() {
const {href, children, attributes} = this.props;
const dataAttributes = {
'data-hashtag': attributes['data-hashtag'],
'data-link': attributes['data-link'],
'data-channel-mention': attributes['data-channel-mention'],
};
return (
<>
{ReactDOM.createPortal(
<div
style={tooltipContainerStyles}
ref={this.tooltipContainerRef}
className={classNames('tooltip-container', {visible: this.state.show})}
>
<Pluggable
href={href}
show={this.state.show}
pluggableName='LinkTooltip'
/>
</div>,
document.getElementById('root') as HTMLElement,
)}
<span
onMouseOver={this.showTooltip}
onMouseLeave={this.hideTooltip}
{...dataAttributes}
>
{children}
</span>
</>
);
}
}

Просмотреть файл

@@ -0,0 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
useFloating,
autoUpdate,
safePolygon,
useHover,
useDismiss,
useInteractions,
FloatingPortal,
autoPlacement,
inline,
useTransitionStyles,
FloatingOverlay,
FloatingFocusManager,
useFocus,
} from '@floating-ui/react';
import React, {useState} from 'react';
import type {AnchorHTMLAttributes, ReactElement} from 'react';
import Pluggable from 'plugins/pluggable';
import {RootHtmlPortalId, OverlaysTimings, OverlayTransitionStyles} from 'utils/constants';
import './plugin_link_tooltip.scss';
interface Props {
nodeAttributes: AnchorHTMLAttributes<HTMLAnchorElement>;
children: ReactElement;
}
/**
* A key drawback of this component is that it gets attached to all links in the app if any installed plugin
* supports link previews. Ideally plugins should have provided a regex matcher upfront, allowing us to
* conditionally render the component only when needed.
*/
export default function PluginLinkTooltip(props: Props) {
const [isOpen, setOpen] = useState(false);
const {refs: {setReference, setFloating}, floatingStyles, context: floatingContext} = useFloating({
open: isOpen,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [
inline(),
autoPlacement({
allowedPlacements: ['top', 'bottom'],
}),
],
});
const {isMounted, styles: transitionStyles} = useTransitionStyles(floatingContext, TRANSITION_STYLE_PROPS);
const hoverInteractions = useHover(floatingContext, HOVER_PROPS);
const focusInteractions = useFocus(floatingContext);
const dismissInteraction = useDismiss(floatingContext);
const {getReferenceProps, getFloatingProps} = useInteractions([
hoverInteractions,
focusInteractions,
dismissInteraction,
]);
return (
<>
<a
ref={setReference}
{...props.nodeAttributes}
{...getReferenceProps()}
>
{props.children}
</a>
{isMounted && (
<FloatingPortal id={RootHtmlPortalId}>
<FloatingOverlay className='plugin-link-tooltip-floating-overlay'>
<FloatingFocusManager context={floatingContext}>
<div
ref={setFloating}
style={{...floatingStyles, ...transitionStyles}}
{...getFloatingProps()}
>
<Pluggable
href={props.nodeAttributes.href}
show={true}
pluggableName='LinkTooltip'
/>
</div>
</FloatingFocusManager>
</FloatingOverlay>
</FloatingPortal>
)}
</>
);
}
const TRANSITION_STYLE_PROPS = {
duration: {
open: OverlaysTimings.FADE_IN_DURATION,
close: OverlaysTimings.FADE_OUT_DURATION,
},
initial: OverlayTransitionStyles.START,
};
const HOVER_PROPS = {
restMs: OverlaysTimings.CURSOR_REST_TIME_BEFORE_OPEN,
move: false,
handleClose: safePolygon({
requireIntent: false,
blockPointerEvents: true,
}),
};

Просмотреть файл

@@ -0,0 +1,20 @@
@use 'utils/variables';
.plugin-link-tooltip-floating-overlay {
z-index: variables.$z-index-popover;
}
// This is as per UX guidelines what the container of the
// plugin link tooltip should be, not included currently
// as it will be a breaking change for the plugin's implemented
//link tooltips with styling
.plugin-link-tooltip-container {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
background: var(--center-channel-bg);
box-shadow: var(--elevation-4);
.plugin-link-tooltip-arrow {
fill: var(--center-channel-bg);
}
}

Просмотреть файл

@@ -144,26 +144,18 @@ Array [
exports[`messageHtmlToComponent link with enabled a tooltip plugin 1`] = `
<p>
lorem ipsum
<a
className="theme markdown__link"
href="http://www.dolor.com"
rel="noreferrer"
target="_blank"
>
<LinkTooltip
attributes={
Object {
"class": "theme markdown__link",
"href": "http://www.dolor.com",
"rel": "noreferrer",
"target": "_blank",
}
<PluginLinkTooltip
nodeAttributes={
Object {
"class": "theme markdown__link",
"href": "http://www.dolor.com",
"rel": "noreferrer",
"target": "_blank",
}
href="http://www.dolor.com"
>
www.dolor.com
</LinkTooltip>
</a>
}
>
www.dolor.com
</PluginLinkTooltip>
sit amet
</p>
`;

Просмотреть файл

@@ -2009,8 +2009,6 @@ export const Constants = {
COMMAND_SUGGESTION_CHANNEL: 'channel',
COMMAND_SUGGESTION_USER: 'user',
},
OVERLAY_TIME_DELAY_SMALL: 100,
OVERLAY_TIME_DELAY: 400,
PERMALINK_FADEOUT: 5000,
DEFAULT_MAX_USERS_PER_TEAM: 50,
DEFAULT_MAX_CHANNELS_PER_TEAM: 2000,

Просмотреть файл

@@ -10,8 +10,8 @@ import AtSumOfMembersMention from 'components/at_sum_members_mention';
import CodeBlock from 'components/code_block/code_block';
import LatexBlock from 'components/latex_block';
import LatexInline from 'components/latex_inline';
import LinkTooltip from 'components/link_tooltip/link_tooltip';
import MarkdownImage from 'components/markdown_image';
import PluginLinkTooltip from 'components/plugin_link_tooltip';
import PostEmoji from 'components/post_emoji';
import PostEditedIndicator from 'components/post_view/post_edited_indicator';
@@ -107,18 +107,14 @@ export function messageHtmlToComponent(html: string, options: Options = {}) {
];
if (options.hasPluginTooltips) {
const hrefAttrib = 'href';
processingInstructions.push({
replaceChildren: true,
shouldProcessNode: (node: any) => node.type === 'tag' && node.name === 'a' && node.attribs[hrefAttrib],
replaceChildren: false,
shouldProcessNode: (node: any) => node.type === 'tag' && node.name === 'a' && node.attribs.href,
processNode: (node: any, children: any) => {
return (
<LinkTooltip
href={node.attribs[hrefAttrib]}
attributes={node.attribs}
>
<PluginLinkTooltip nodeAttributes={node.attribs}>
{children}
</LinkTooltip>
</PluginLinkTooltip>
);
},
});