[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
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
cf219b0e06
Коммит
f80afad1b6
@@ -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
сгенерированный
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",
|
||||
|
||||
Ссылка в новой задаче
Block a user