[MM-62381] Move LinkTooltip from Popper to Floating-UI (#29727)
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
a3b2ecec19
Коммит
db3b72e46c
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
112
webapp/channels/src/components/plugin_link_tooltip/index.tsx
Обычный файл
112
webapp/channels/src/components/plugin_link_tooltip/index.tsx
Обычный файл
@@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Ссылка в новой задаче
Block a user