[MM-58441] Create a floating-ui Tooltip which will swap out ReactBootstrap tooltip (#29464)

Added a new tooltip
- components/team_sidebar/components/team_button.tsx
- components/post_view/reaction/reaction_tooltip/reaction_tooltip.tsx
- components/post_view/post_recent_reactions/post_recent_reactions.tsx
- components/common/comment_icon.tsx
Этот коммит содержится в:
M-ZubairAhmed
2024-12-09 20:20:08 +05:30
коммит произвёл GitHub
родитель cf219b0e06
Коммит f80afad1b6
32 изменённых файлов: 829 добавлений и 287 удалений

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

@@ -35,7 +35,7 @@ describe('Messaging', () => {
it('MM-T133 Visual verification of tooltips on post hover menu', () => {
cy.getLastPostId().then((postId) => {
verifyToolTip(postId, `#CENTER_button_${postId}`, 'More');
verifyToolTip(postId, `#CENTER_flagIcon_${postId}`, 'Save Message');
verifyToolTip(postId, `#CENTER_reaction_${postId}`, 'Add Reaction');
@@ -46,10 +46,10 @@ describe('Messaging', () => {
function verifyToolTip(postId, targetElement, label) {
cy.get(`#post_${postId}`).trigger('mouseover');
cy.get(targetElement).trigger('mouseover', {force: true});
cy.get(targetElement).trigger('mouseenter', {force: true});
cy.findByText(label).should('be.visible');
cy.get(targetElement).trigger('mouseout', {force: true});
cy.get(targetElement).trigger('mouseleave', {force: true});
cy.findByText(label).should('not.exist');
}
});

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

@@ -6,7 +6,7 @@
"version": "9.3.0",
"private": true,
"dependencies": {
"@floating-ui/react": "0.26.6",
"@floating-ui/react": "0.26.28",
"@giphy/js-fetch-api": "5.1.0",
"@giphy/react-components": "8.1.0",
"@guyplusplus/turndown-plugin-gfm": "1.0.7",

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

@@ -12,6 +12,8 @@ import WithTooltip from 'components/with_tooltip';
import {AnnouncementBarTypes} from 'utils/constants';
import {isStringContainingUrl} from 'utils/url';
import './default_announcement_bar.scss';
type Props = {
id?: string;
showCloseButton: boolean;

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

@@ -0,0 +1,5 @@
#announcement-bar__tooltip {
width: 50%;
max-width: 100%;
pointer-events: auto;
}

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

@@ -4,6 +4,8 @@
import classNames from 'classnames';
import React from 'react';
import type {MouseEvent, ReactNode, RefObject} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {OverlayTrigger as BaseOverlayTrigger} from 'react-bootstrap';
import {Overlay} from 'react-bootstrap';
import {FormattedMessage, injectIntl} from 'react-intl';
import type {IntlShape} from 'react-intl';
@@ -18,7 +20,6 @@ import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import CustomStatusText from 'components/custom_status/custom_status_text';
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
import Markdown from 'components/markdown';
import type {BaseOverlayTrigger} from 'components/overlay_trigger';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import Timestamp from 'components/timestamp';
import Popover from 'components/widgets/popover';
@@ -44,6 +45,10 @@ import HeaderIconWrapper from './components/header_icon_wrapper';
const headerMarkdownOptions = {singleline: true, mentionHighlight: false, atMentions: true};
const popoverMarkdownOptions = {singleline: false, mentionHighlight: false, atMentions: true};
export type OverlayTrigger = BaseOverlayTrigger & {
hide: () => void;
};
export type Props = {
teamId: string;
currentUser: UserProfile;
@@ -94,7 +99,7 @@ class ChannelHeader extends React.PureComponent<Props, State> {
toggleFavoriteRef: RefObject<HTMLButtonElement>;
headerDescriptionRef: RefObject<HTMLSpanElement>;
headerPopoverTextMeasurerRef: RefObject<HTMLDivElement>;
headerOverlayRef: RefObject<BaseOverlayTrigger>;
headerOverlayRef: RefObject<OverlayTrigger>;
getHeaderMarkdownOptions: (channelNamesMap: Record<string, any>) => Record<string, any>;
getPopoverMarkdownOptions: (channelNamesMap: Record<string, any>) => Record<string, any>;

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

@@ -5,7 +5,7 @@ import React from 'react';
import {useIntl} from 'react-intl';
import ReplyIcon from 'components/widgets/icons/reply_icon';
import WithTooltip from 'components/with_tooltip';
import WithTooltip from 'components/with_tooltip/with_tooltip_new';
import type {Locations} from 'utils/constants';
@@ -43,8 +43,6 @@ const CommentIcon = ({
return (
<WithTooltip
id='comment-icon-tooltip'
placement='top'
title={intl.formatMessage({
id: 'post_info.comment_icon.tooltip.reply',
defaultMessage: 'Reply',

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

@@ -1,179 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {mount} from 'enzyme';
import React from 'react';
import {OverlayTrigger as BaseOverlayTrigger} from 'react-bootstrap'; // eslint-disable-line no-restricted-imports
import {FormattedMessage, IntlProvider} from 'react-intl';
import {Provider as ReduxProvider} from 'react-redux';
import type {Store} from 'redux';
import testConfigureStore from 'packages/mattermost-redux/test/test_store';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import OverlayTrigger from './overlay_trigger';
describe('OverlayTrigger', () => {
const testId = 'test.value';
let store: Store;
const intlProviderProps = {
defaultLocale: 'en',
locale: 'en',
messages: {
[testId]: 'Actual value',
},
};
const baseProps = {
overlay: (
<FormattedMessage
id={testId}
defaultMessage='Default value'
/>
),
};
// Intercept console error messages since we intentionally cause some as part of these tests
let originalConsoleError: () => void;
beforeEach(() => {
store = testConfigureStore();
originalConsoleError = console.error;
console.error = jest.fn();
});
afterEach(() => {
console.error = originalConsoleError;
});
test('base OverlayTrigger should fail to pass intl to overlay', () => {
const wrapper = mount(
<ReduxProvider store={store}>
<IntlProvider {...intlProviderProps}>
<BaseOverlayTrigger {...baseProps}>
<span/>
</BaseOverlayTrigger>
</IntlProvider>
</ReduxProvider>,
);
// console.error will have been called by FormattedMessage because its intl context is missing
expect(() => {
mount(wrapper.find(BaseOverlayTrigger).prop('overlay'));
}).toThrow('[React Intl] Could not find required `intl` object. <IntlProvider> needs to exist in the component ancestry.');
});
test('custom OverlayTrigger should pass intl to overlay', () => {
const wrapper = mount(
<ReduxProvider store={store}>
<IntlProvider {...intlProviderProps}>
<OverlayTrigger {...baseProps}>
<span/>
</OverlayTrigger>
</IntlProvider>
</ReduxProvider>,
);
const overlay = mount(wrapper.find(BaseOverlayTrigger).prop('overlay'));
expect(overlay.text()).toBe('Actual value');
expect(console.error).not.toHaveBeenCalled();
});
test('ref should properly be forwarded', () => {
const ref = React.createRef<BaseOverlayTrigger>();
const props = {
...baseProps,
ref,
};
const wrapper = mountWithIntl(
<ReduxProvider store={store}>
<IntlProvider {...intlProviderProps}>
<OverlayTrigger {...props}>
<span/>
</OverlayTrigger>
</IntlProvider>
</ReduxProvider>,
);
expect(ref.current).toBe(wrapper.find(BaseOverlayTrigger).instance());
});
test('style and className should correctly be passed to overlay', () => {
const props = {
...baseProps,
overlay: (
<span
className='test-overlay-className'
style={{backgroundColor: 'red'}}
>
{'test-overlay'}
</span>
),
defaultOverlayShown: true, // Make sure the overlay is visible
};
const wrapper = mount(
<ReduxProvider store={store}>
<IntlProvider {...intlProviderProps}>
<OverlayTrigger {...props}>
<span/>
</OverlayTrigger>
</IntlProvider>
</ReduxProvider>,
);
// Dive into the react-bootstrap internals to find our overlay
const overlay = mount((wrapper.find(BaseOverlayTrigger).instance() as any)._overlay).find('span'); // eslint-disable-line no-underscore-dangle
// Confirm that we've found the right span
expect(overlay.exists()).toBe(true);
expect(overlay.text()).toBe('test-overlay');
// Confirm that our props are included
expect(overlay.prop('className')).toContain('test-overlay-className');
expect(overlay.prop('style')).toMatchObject({backgroundColor: 'red'});
// And confirm that react-bootstrap's props are included
expect(overlay.prop('className')).toContain('fade in');
expect(overlay.prop('placement')).toBe('right');
expect(overlay.prop('positionTop')).toBe(0);
});
test('disabled and style should both be supported', () => {
const props = {
...baseProps,
overlay: (
<span
style={{backgroundColor: 'red'}}
>
{'test-overlay'}
</span>
),
defaultOverlayShown: true, // Make sure the overlay is visible
disabled: true,
};
const wrapper = mount(
<ReduxProvider store={store}>
<IntlProvider {...intlProviderProps}>
<OverlayTrigger {...props}>
<span/>
</OverlayTrigger>
</IntlProvider>
</ReduxProvider>,
);
// Dive into the react-bootstrap internals to find our overlay
const overlay = mount((wrapper.find(BaseOverlayTrigger).instance() as any)._overlay).find('span'); // eslint-disable-line no-underscore-dangle
// Confirm that we've found the right span
expect(overlay.exists()).toBe(true);
expect(overlay.text()).toBe('test-overlay');
// Confirm that our props are included
expect(overlay.prop('style')).toMatchObject({backgroundColor: 'red', visibility: 'hidden'});
});
});

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

@@ -1,10 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/post_view/PostFlagIcon should match snapshot 1`] = `
<WithTooltip
id="flagTooltip"
<Memo(WithTooltip)
key="flagtooltipkey"
placement="top"
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Save Message"
@@ -22,14 +20,12 @@ exports[`components/post_view/PostFlagIcon should match snapshot 1`] = `
className="icon icon--small"
/>
</button>
</WithTooltip>
</Memo(WithTooltip)>
`;
exports[`components/post_view/PostFlagIcon should match snapshot 2`] = `
<WithTooltip
id="flagTooltip"
<Memo(WithTooltip)
key="flagtooltipkeyflagged"
placement="top"
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Remove from Saved"
@@ -47,5 +43,5 @@ exports[`components/post_view/PostFlagIcon should match snapshot 2`] = `
className="icon icon--small icon--small-filled post-menu__item--selected"
/>
</button>
</WithTooltip>
</Memo(WithTooltip)>
`;

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

@@ -7,7 +7,7 @@ import {FormattedMessage, useIntl} from 'react-intl';
import FlagIcon from 'components/widgets/icons/flag_icon';
import FlagIconFilled from 'components/widgets/icons/flag_icon_filled';
import WithTooltip from 'components/with_tooltip';
import WithTooltip from 'components/with_tooltip/with_tooltip_new';
import {Locations, A11yCustomEventTypes} from 'utils/constants';
@@ -82,9 +82,7 @@ const PostFlagIcon = ({
return (
<WithTooltip
id='flagTooltip'
key={`flagtooltipkey${isFlagged ? 'flagged' : ''}`}
placement='top'
title={
isFlagged ? (
<FormattedMessage

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

@@ -17,9 +17,7 @@ exports[`components/post_view/PostReaction should match snapshot 1`] = `
target={[MockFunction]}
topOffset={-7}
/>
<WithTooltip
id="reaction-icon-tooltip"
placement="top"
<Memo(WithTooltip)
title={
Object {
"defaultMessage": "Add Reaction",
@@ -38,6 +36,6 @@ exports[`components/post_view/PostReaction should match snapshot 1`] = `
className="icon icon--small"
/>
</button>
</WithTooltip>
</Memo(WithTooltip)>
</Memo(ChannelPermissionGate)>
`;

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

@@ -13,7 +13,7 @@ import {getEmojiName} from 'mattermost-redux/utils/emoji_utils';
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import EmojiIcon from 'components/widgets/icons/emoji_icon';
import WithTooltip from 'components/with_tooltip';
import WithTooltip from 'components/with_tooltip/with_tooltip_new';
import {Locations} from 'utils/constants';
import {localizeMessage} from 'utils/utils';
@@ -91,9 +91,7 @@ export default class PostReaction extends React.PureComponent<Props, State> {
spaceRequiredBelow={spaceRequiredBelow}
/>
<WithTooltip
id='reaction-icon-tooltip'
title={messages.addReaction}
placement='top'
>
<button
data-testid='post-reaction-emoji-icon'

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

@@ -9,7 +9,7 @@ import Permissions from 'mattermost-redux/constants/permissions';
import {getEmojiName} from 'mattermost-redux/utils/emoji_utils';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import WithTooltip from 'components/with_tooltip';
import WithTooltip from 'components/with_tooltip/with_tooltip_new';
import {Locations} from 'utils/constants';
@@ -92,20 +92,16 @@ export default class PostRecentReactions extends React.PureComponent<Props, Stat
permissions={[Permissions.ADD_REACTION]}
>
<WithTooltip
id='post_info.emoji.tooltip'
title={this.emojiName(emoji, this.props.locale)}
emoji={getEmojiName(emoji)}
emojiStyle='large'
placement='top'
isEmojiLarge={true}
>
<div>
<>
<EmojiItem
emoji={emoji}
onItemClick={this.handleToggleEmoji}
order={n}
/>
</>
<EmojiItem
emoji={emoji}
onItemClick={this.handleToggleEmoji}
order={n}
/>
</div>
</WithTooltip>
</ChannelPermissionGate>

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

@@ -6,7 +6,6 @@ exports[`components/post_view/Reaction should apply read-only class if user does
canRemoveReactions={true}
currentUserReacted={false}
emojiName="smile"
id="post_id-smile-reaction"
onShow={[Function]}
reactions={
Array [
@@ -73,7 +72,6 @@ exports[`components/post_view/Reaction should apply read-only class if user does
canRemoveReactions={false}
currentUserReacted={true}
emojiName="smile"
id="post_id-smile-reaction"
onShow={[Function]}
reactions={
Array [
@@ -140,7 +138,6 @@ exports[`components/post_view/Reaction should match snapshot 1`] = `
canRemoveReactions={true}
currentUserReacted={false}
emojiName="smile"
id="post_id-smile-reaction"
onShow={[Function]}
reactions={
Array [
@@ -207,7 +204,6 @@ exports[`components/post_view/Reaction should match snapshot when a current user
canRemoveReactions={true}
currentUserReacted={true}
emojiName="smile"
id="post_id-smile-reaction"
onShow={[Function]}
reactions={
Array [

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

@@ -210,7 +210,6 @@ export default class Reaction extends React.PureComponent<Props, State> {
return (
<ReactionTooltip
id={`${this.props.post.id}-${this.props.emojiName}-reaction`}
canAddReactions={canAddReactions}
canRemoveReactions={canRemoveReactions}
currentUserReacted={currentUserReacted}

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

@@ -6,7 +6,7 @@ import {useIntl} from 'react-intl';
import type {Reaction as ReactionType} from '@mattermost/types/reactions';
import WithTooltip from 'components/with_tooltip';
import WithTooltip from 'components/with_tooltip/with_tooltip_new';
type Props = {
canAddReactions: boolean;
@@ -14,7 +14,6 @@ type Props = {
children: React.ReactNode;
currentUserReacted: boolean;
emojiName: string;
id: string;
onShow: () => void;
reactions: ReactionType[];
users: string[];
@@ -27,7 +26,6 @@ const ReactionTooltip: React.FC<Props> = (props: Props) => {
children,
currentUserReacted,
emojiName,
id,
onShow,
reactions,
users,
@@ -101,7 +99,7 @@ const ReactionTooltip: React.FC<Props> = (props: Props) => {
});
}
const tooltip = intl.formatMessage(
const tooltipTitle = intl.formatMessage(
{
id: 'reaction.reacted',
defaultMessage: '{users} {reactionVerb} with {emoji}',
@@ -113,14 +111,14 @@ const ReactionTooltip: React.FC<Props> = (props: Props) => {
},
);
let clickTooltip;
let tooltipHint;
if (currentUserReacted && canRemoveReactions) {
clickTooltip = intl.formatMessage({
tooltipHint = intl.formatMessage({
id: 'reaction.clickToRemove',
defaultMessage: '(click to remove)',
});
} else if (!currentUserReacted && canAddReactions) {
clickTooltip = intl.formatMessage({
tooltipHint = intl.formatMessage({
id: 'reaction.clickToAdd',
defaultMessage: '(click to add)',
});
@@ -128,13 +126,11 @@ const ReactionTooltip: React.FC<Props> = (props: Props) => {
return (
<WithTooltip
id={id}
title={tooltipTitle}
hint={tooltipHint}
emoji={emojiName}
emojiStyle='large'
placement='top'
title={tooltip}
hint={clickTooltip}
onShow={onShow}
isEmojiLarge={true}
onOpen={onShow}
>
{children}
</WithTooltip>

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

@@ -128,7 +128,7 @@ export function ProfilePopoverController<TriggerComponentType = HTMLSpanElement>
</TriggerComponent>
{isMounted && (
<FloatingPortal id='user-profile-popover-portal'>
<FloatingPortal id='root-portal'>
<FloatingOverlay
id='user-profile-popover-floating-overlay'
className='user-profile-popover-floating-overlay'

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

@@ -10,8 +10,8 @@ import {Link} from 'react-router-dom';
import {mark, trackEvent} from 'actions/telemetry_actions.jsx';
import TeamIcon from 'components/widgets/team_icon/team_icon';
import WithTooltip from 'components/with_tooltip';
import {ShortcutKeys} from 'components/with_tooltip/shortcut';
import WithTooltip from 'components/with_tooltip/with_tooltip_new';
import {Mark} from 'utils/performance_telemetry';
@@ -153,7 +153,6 @@ export default function TeamButton({
<WithTeamTooltip
order={order}
tip={tip}
url={url}
>
<div className={'team-btn ' + btnClass}>
{!otherProps.isInProduct && badge}
@@ -211,9 +210,8 @@ export default function TeamButton({
function WithTeamTooltip({
order,
tip,
url,
children,
}: React.PropsWithChildren<Pick<Props, 'order' | 'tip' | 'url'>>) {
}: React.PropsWithChildren<Pick<Props, 'order' | 'tip'>>) {
const intl = useIntl();
const shortcut = useMemo(() => {
@@ -229,10 +227,8 @@ function WithTeamTooltip({
return (
<WithTooltip
id={`tooltip-${url}`}
title={tip || intl.formatMessage(messages.nameUndefined)}
shortcut={shortcut}
placement='right'
>
{children}
</WithTooltip>

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

@@ -83,7 +83,7 @@ export function UserGroupPopoverController(props: Props) {
</span>
{isMounted && (
<FloatingPortal id='user-group-popover-portal'>
<FloatingPortal id='root-portal'>
<FloatingOverlay
id='user-group-popover-floating-overlay'
className='user-group-popover-floating-overlay'

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

@@ -6,11 +6,11 @@ import type {ComponentProps} from 'react';
import {type MessageDescriptor} from 'react-intl';
import RenderEmoji from 'components/emoji/render_emoji';
import Tooltip from 'components/tooltip';
import {formatAsComponent} from 'utils/i18n';
import {TooltipShortcutSequence, type ShortcutDefinition} from './shortcut';
import BSTooltip from './tooltip';
type EmojiStyle = 'inline' | 'large' | undefined;
@@ -24,7 +24,7 @@ export type CommonTooltipProps = {
}
export function createTooltip(commonTooltipProps: CommonTooltipProps) {
return (props: Omit<ComponentProps<typeof Tooltip>, 'children' | 'id'>) => {
return (props: Omit<ComponentProps<typeof BSTooltip>, 'children' | 'id'>) => {
const contents = [];
if (commonTooltipProps.emoji && commonTooltipProps.emojiStyle === 'large') {
@@ -90,12 +90,12 @@ export function createTooltip(commonTooltipProps: CommonTooltipProps) {
}
return (
<Tooltip
<BSTooltip
{...props}
id={commonTooltipProps.id}
>
{contents}
</Tooltip>
</BSTooltip>
);
};
}

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

@@ -4,12 +4,11 @@
import React, {useMemo} from 'react';
import type {ComponentProps} from 'react';
import OverlayTrigger from 'components/overlay_trigger';
import Constants from 'utils/constants';
import type {CommonTooltipProps} from './create_tooltip';
import {createTooltip} from './create_tooltip';
import OverlayTrigger from './overlay_trigger';
type OverlayTriggerProps = ComponentProps<typeof OverlayTrigger>;
@@ -20,6 +19,7 @@ type WithTooltipProps = {
delayHide?: number;
disabled?: boolean;
} & CommonTooltipProps;
const WithTooltip = ({
id,
title,

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

@@ -18,7 +18,7 @@ type Props = OverlayTriggerProps & {
};
/**
* @deprecated Use (and expand when extrictly needed) WithTooltip instead
* @deprecated Use (and expand when strictly needed) WithTooltip instead
*/
const OverlayTrigger = React.forwardRef((props: Props, ref?: React.Ref<OriginalOverlayTrigger>) => {
const {overlay, disabled, ...otherProps} = props;

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

@@ -0,0 +1,140 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TooltipContent have correct structure with just title 1`] = `
<div>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Title
</span>
</span>
</div>
</div>
`;
exports[`TooltipContent have correct structure with title and emoji 1`] = `
<div>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentEmoji"
>
<span
aria-label=":smile:"
class="emoticon"
data-emoticon="smile"
style="background-image: url(/static/emoji/1f604.png); background-size: contain; height: 16px; width: 16px; max-height: 16px; max-width: 16px; min-height: 16px; min-width: 16px; overflow: hidden;"
/>
</span>
<span
class="tooltipContentTitle"
>
Title with Emoji
</span>
</span>
</div>
</div>
`;
exports[`TooltipContent have correct structure with title and large emoji 1`] = `
<div>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer isEmojiLarge"
>
<span
class="tooltipContentEmoji"
>
<span
aria-label=":smile:"
class="emoticon"
data-emoticon="smile"
style="background-image: url(/static/emoji/1f604.png); background-size: contain; height: 48px; width: 48px; max-height: 48px; max-width: 48px; min-height: 48px; min-width: 48px; overflow: hidden;"
/>
</span>
<span
class="tooltipContentTitle"
>
Title with Large Emoji
</span>
</span>
</div>
</div>
`;
exports[`TooltipContent have correct structure with title and shortcut 1`] = `
<div>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Title with Shortcut
</span>
</span>
<span
class="tooltipContentShortcut"
>
<mark
class="shortcut-key shortcut-key--tooltip"
>
Ctrl
</mark>
<mark
class="shortcut-key shortcut-key--tooltip"
>
K
</mark>
</span>
</div>
</div>
`;
exports[`TooltipContent have correct structure with title, emoji and hint 1`] = `
<div>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentEmoji"
>
<span
aria-label=":smile:"
class="emoticon"
data-emoticon="smile"
style="background-image: url(/static/emoji/1f604.png); background-size: contain; height: 16px; width: 16px; max-height: 16px; max-width: 16px; min-height: 16px; min-width: 16px; overflow: hidden;"
/>
</span>
<span
class="tooltipContentTitle"
>
Title with Hint
</span>
</span>
<span
class="tooltipContentHint"
>
This is a hint
</span>
</div>
</div>
`;

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

@@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import userEvent from '@testing-library/user-event';
import React from 'react';
import {renderWithContext, screen, waitFor, act} from 'tests/react_testing_utils';
import WithTooltip from './index';
jest.mock('utils/user_agent', () => ({
isMac: jest.fn().mockReturnValue(false),
}));
describe('WithTooltip', () => {
test('renders children correctly', async () => {
renderWithContext(
<WithTooltip title='TooltipOfButton'>
<button>{'I am a button surrounded by a tooltip'}</button>
</WithTooltip>,
);
expect(screen.getByText('I am a button surrounded by a tooltip')).toBeInTheDocument();
});
test('shows tooltip on hover', async () => {
jest.useFakeTimers();
renderWithContext(
<WithTooltip title='Tooltip will appear on hover'>
<div>{'Hover Me'}</div>
</WithTooltip>,
);
await act(async () => {
userEvent.hover(screen.getByText('Hover Me'));
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('Tooltip will appear on hover')).toBeInTheDocument();
});
});
});
test('shows tooltip on focus', async () => {
jest.useFakeTimers();
renderWithContext(
<WithTooltip title='Tooltip will appear on hover'>
<button>{'Hover Me'}</button>
</WithTooltip>,
);
await act(async () => {
const trigger = screen.getByText('Hover Me');
// Clicking the button will simulate a focus event
userEvent.click(trigger);
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(trigger).toHaveFocus();
expect(screen.getByText('Tooltip will appear on hover')).toBeInTheDocument();
});
});
});
test('calls onOpen when tooltip appears', async () => {
const onOpen = jest.fn();
jest.useFakeTimers();
renderWithContext(
<WithTooltip
title='Tooltip will appear on hover'
onOpen={onOpen}
>
<div>{'Hover Me'}</div>
</WithTooltip>,
);
await act(async () => {
expect(onOpen).not.toHaveBeenCalled();
userEvent.hover(screen.getByText('Hover Me'));
jest.advanceTimersByTime(1000);
await waitFor(() => {
expect(screen.getByText('Tooltip will appear on hover')).toBeInTheDocument();
expect(onOpen).toHaveBeenCalled();
});
});
});
});

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

@@ -0,0 +1,204 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Placement} from '@floating-ui/react';
import {
useFloating,
autoUpdate,
offset,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
arrow,
FloatingPortal,
useTransitionStyles,
FloatingArrow,
flip,
} from '@floating-ui/react';
import React, {useRef, useState, memo, useMemo, cloneElement, isValidElement} from 'react';
import type {ReactNode} from 'react';
import type {MessageDescriptor} from 'react-intl';
import {defineMessage} from 'react-intl';
import {Constants} from 'utils/constants';
import TooltipContent from './tooltip_content';
import type {ShortcutDefinition} from './tooltip_shortcut';
import './tooltip.scss';
const ARROW_WIDTH = 10; // in px
const ARROW_HEIGHT = 6; // in px
const ARROW_OFFSET = 8; // in px
const TOOLTIP_REST_TIME_BEFORE_OPEN = 400; // in ms
const TOOLTIP_APPEAR_DURATION = 250; // in ms
const TOOLTIP_DISAPPEAR_DURATION = 200; // in ms
export const ShortcutKeys = {
alt: defineMessage({
id: 'shortcuts.generic.alt',
defaultMessage: 'Alt',
}),
cmd: '⌘',
ctrl: defineMessage({
id: 'shortcuts.generic.ctrl',
defaultMessage: 'Ctrl',
}),
option: '⌥',
shift: defineMessage({
id: 'shortcuts.generic.shift',
defaultMessage: 'Shift',
}),
};
interface Props {
title: string | ReactNode | MessageDescriptor;
emoji?: string;
isEmojiLarge?: boolean;
hint?: string;
shortcut?: ShortcutDefinition;
/**
* Whether the tooltip should be vertical or horizontal, by default it is vertical
* This doesn't always guarantee the tooltip will be vertical, it just determines the initial placement and fallback placements
*/
isVertical?: boolean;
/**
* @deprecated Do not use this except for special cases
* Callback when the tooltip appears
*/
onOpen?: () => void;
children: ReactNode;
}
function WithTooltip({
children,
title,
emoji,
isEmojiLarge = false,
hint,
shortcut,
isVertical = true,
onOpen,
}: Props) {
const [open, setOpen] = useState(false);
const arrowRef = useRef(null);
function handleChange(open: boolean) {
setOpen(open);
if (onOpen && open) {
onOpen();
}
}
const placements = useMemo<{initial: Placement; fallback: Placement[]}>(() => {
let initial: Placement;
let fallback: Placement[];
if (isVertical) {
initial = 'top';
fallback = ['bottom', 'right', 'left'];
} else {
initial = 'right';
fallback = ['left', 'top', 'bottom'];
}
return {initial, fallback};
}, [isVertical]);
const {refs: {setReference, setFloating}, floatingStyles, context} = useFloating({
open,
onOpenChange: handleChange,
whileElementsMounted: autoUpdate,
placement: placements.initial,
middleware: [
offset(ARROW_OFFSET),
flip({
fallbackPlacements: placements.fallback,
}),
arrow({
element: arrowRef,
}),
],
});
const hover = useHover(context, {
restMs: TOOLTIP_REST_TIME_BEFORE_OPEN,
delay: {
open: Constants.OVERLAY_TIME_DELAY,
},
});
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, {role: 'tooltip'});
const {getReferenceProps, getFloatingProps} = useInteractions([hover, focus, dismiss, role]);
const {isMounted, styles: transitionStyles} = useTransitionStyles(context, {
duration: {
open: TOOLTIP_APPEAR_DURATION,
close: TOOLTIP_DISAPPEAR_DURATION,
},
initial: {
opacity: 0,
},
common: {
opacity: 1,
},
});
if (!isValidElement(children)) {
// eslint-disable-next-line no-console
console.error('Children must be a valid React element for WithTooltip');
return null;
}
const trigger = cloneElement(children, {
...getReferenceProps({
ref: setReference,
...children.props,
}),
});
return (
<>
{trigger}
{isMounted && (
<FloatingPortal
id='root-portal' // This is the global portal container id
>
<div
className='tooltipContainer'
ref={setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
<div
className='tooltipContentContainer'
style={transitionStyles}
>
<TooltipContent
title={title}
emoji={emoji}
isEmojiLarge={isEmojiLarge}
hint={hint}
shortcut={shortcut}
/>
<FloatingArrow
ref={arrowRef}
context={context}
width={ARROW_WIDTH}
height={ARROW_HEIGHT}
/>
</div>
</div>
</FloatingPortal>
)}
</>
);
}
export default memo(WithTooltip);

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

@@ -0,0 +1,70 @@
.tooltipContainer {
z-index: 1070;
> .tooltipContentContainer {
z-index: 1070;
max-width: 220px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 1);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.12);
line-height: 18px;
pointer-events: none;
text-align: center;
word-break: break-word;
> .tooltipContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: "Open Sans", sans-serif;
> .tooltipContentTitleContainer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
&.isEmojiLarge {
flex-direction: column;
gap: 2px;
> .tooltipContentEmoji {
padding-top: 1px;
}
}
> .tooltipContentEmoji {
display: flex;
align-items: center;
justify-content: center;
}
> .tooltipContentTitle {
color: #ffffff;
font-size: 12px;
font-weight: 600;
line-height: 15px;
}
}
> .tooltipContentShortcut {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 4px 0;
gap: 2px;
}
> .tooltipContentHint {
color: rgba(255, 255, 255, 0.64);
font-size: 11px;
font-weight: 600;
line-height: 16px;
}
}
}
}

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

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import TooltipContent from './tooltip_content';
describe('TooltipContent', () => {
test('have correct structure with just title', () => {
const {container} = renderWithContext(
<TooltipContent title='Title'/>,
);
expect(container).toMatchSnapshot();
});
test('have correct structure with title and emoji', () => {
const {container} = renderWithContext(
<TooltipContent
title='Title with Emoji'
emoji='smile'
/>,
);
expect(container).toMatchSnapshot();
});
test('have correct structure with title and large emoji', () => {
const {container} = renderWithContext(
<TooltipContent
title='Title with Large Emoji'
emoji='smile'
isEmojiLarge={true}
/>,
);
expect(container).toMatchSnapshot();
});
test('have correct structure with title, emoji and hint', () => {
const {container} = renderWithContext(
<TooltipContent
title='Title with Hint'
hint='This is a hint'
emoji='smile'
/>,
);
expect(container).toMatchSnapshot();
});
test('have correct structure with title and shortcut', () => {
const shortcut = {
default: ['Ctrl', 'K'],
};
const {container} = renderWithContext(
<TooltipContent
title='Title with Shortcut'
shortcut={shortcut}
/>,
);
expect(container).toMatchSnapshot();
});
});

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

@@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import type {ReactNode} from 'react';
import React, {memo} from 'react';
import type {MessageDescriptor} from 'react-intl';
import {useIntl} from 'react-intl';
import RenderEmoji from 'components/emoji/render_emoji';
import {isMessageDescriptor} from 'utils/i18n';
import TooltipShortcut from './tooltip_shortcut';
import {type ShortcutDefinition} from './tooltip_shortcut';
const TOOLTIP_EMOTICON_SIZE = 16;
const TOOLTIP_EMOTICON_LARGE_SIZE = 48;
interface Props {
title: string | ReactNode | MessageDescriptor;
emoji?: string;
isEmojiLarge?: boolean;
hint?: string;
shortcut?: ShortcutDefinition;
}
function TooltipContent(props: Props) {
const {formatMessage} = useIntl();
let title = props.title;
if (isMessageDescriptor(title)) {
title = formatMessage(title);
}
return (
<div className='tooltipContent'>
<span
className={classNames('tooltipContentTitleContainer', {
isEmojiLarge: props.isEmojiLarge,
})}
>
{props.emoji && (
<span className='tooltipContentEmoji'>
<RenderEmoji
emojiName={props.emoji}
size={props.isEmojiLarge ? TOOLTIP_EMOTICON_LARGE_SIZE : TOOLTIP_EMOTICON_SIZE}
/>
</span>
)}
<span className='tooltipContentTitle'>{title}</span>
</span>
{props.hint && (
<span className='tooltipContentHint'>{props.hint}</span>
)}
{props.shortcut && (
<span className='tooltipContentShortcut'>
<TooltipShortcut shortcut={props.shortcut}/>
</span>
)}
</div>
);
}
export default memo(TooltipContent);

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

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {defineMessage} from 'react-intl';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import * as userAgentUtils from 'utils/user_agent';
import TooltipShortcut from './tooltip_shortcut';
jest.mock('utils/user_agent', () => ({
isMac: jest.fn(),
}));
describe('TooltipShortcut', () => {
const isMacMock = jest.mocked(userAgentUtils.isMac);
afterEach(() => {
jest.resetAllMocks();
});
test('should show non mac shortcut when on non mac', () => {
isMacMock.mockReturnValue(false);
const shortcut = {
default: ['Ctrl', 'K'],
mac: ['⌘', 'K'],
};
renderWithContext(
<TooltipShortcut shortcut={shortcut}/>,
);
expect(screen.getByText('Ctrl')).toBeInTheDocument();
expect(screen.getByText('K')).toBeInTheDocument();
expect(screen.queryByText('⌘')).not.toBeInTheDocument();
});
test('should show mac shortcut when on mac', () => {
isMacMock.mockReturnValue(true);
const shortcut = {
default: ['Ctrl', 'K'],
mac: ['⌘', 'K'],
};
renderWithContext(
<TooltipShortcut shortcut={shortcut}/>,
);
expect(screen.getByText('⌘')).toBeInTheDocument();
expect(screen.getByText('K')).toBeInTheDocument();
expect(screen.queryByText('Ctrl')).not.toBeInTheDocument();
});
test('show shortcut with message descriptor', () => {
const shortcut = {
default: [
defineMessage({
id: 'shortcuts.generic.enter',
defaultMessage: 'Enter',
}),
],
};
renderWithContext(
<TooltipShortcut shortcut={shortcut}/>,
);
expect(screen.getByText('Enter')).toBeInTheDocument();
});
});

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

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import type {MessageDescriptor} from 'react-intl';
import {FormattedMessage} from 'react-intl';
import {ShortcutKey, ShortcutKeyVariant} from 'components/shortcut_key';
import {isMessageDescriptor} from 'utils/i18n';
import {isMac} from 'utils/user_agent';
export type ShortcutKeyDescriptor = string | MessageDescriptor;
export type ShortcutDefinition = {
default: ShortcutKeyDescriptor[];
mac?: ShortcutKeyDescriptor[];
}
type Props = {
shortcut: ShortcutDefinition;
}
function TooltipShortcut(props: Props) {
let shortcut = props.shortcut.default;
if (props.shortcut.mac && isMac()) {
shortcut = props.shortcut.mac;
}
return (
<>
{shortcut.map((shortcutKey) => {
let key;
let content;
if (isMessageDescriptor(shortcutKey)) {
key = shortcutKey.id;
content = <FormattedMessage {...shortcutKey}/>;
} else {
key = shortcutKey;
content = shortcutKey;
}
return (
<ShortcutKey
key={key}
variant={ShortcutKeyVariant.Tooltip}
>
{content}
</ShortcutKey>
);
})}
</>
);
}
export default memo(TooltipShortcut);

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

@@ -10,12 +10,6 @@
opacity: 1;
}
&#announcement-bar__tooltip {
width: 50%;
max-width: 100%;
pointer-events: auto;
}
.tooltip-inner {
max-width: 100%;
padding: 4px 8px;
@@ -75,33 +69,3 @@
vertical-align: center;
}
}
.floating-ui-tooltip {
max-width: 220px;
padding: 4px 8px;
border-radius: 4px;
background: #000;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.12);
color: #fff;
font-size: 12px;
font-weight: 600;
line-height: 18px;
opacity: 0;
pointer-events: none;
text-align: center;
transition: opacity 0.15s linear;
word-break: break-word;
&.floating-ui-tooltip--visible {
opacity: 0.9;
transition: opacity 0.15s linear;
}
}
.floating-ui-tooltip-arrow {
position: absolute;
width: 8px;
height: 8px;
background: #000;
transform: rotate(45deg);
}

2
webapp/package-lock.json сгенерированный
Просмотреть файл

@@ -57,7 +57,7 @@
"name": "mattermost-webapp",
"version": "9.3.0",
"dependencies": {
"@floating-ui/react": "0.26.6",
"@floating-ui/react": "0.26.28",
"@giphy/js-fetch-api": "5.1.0",
"@giphy/react-components": "8.1.0",
"@guyplusplus/turndown-plugin-gfm": "1.0.7",