From f80afad1b6ed8a1ae629a342298566684e27ce5c Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Mon, 9 Dec 2024 20:20:08 +0530 Subject: [PATCH] [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 --- .../tooltip_visual_verification_spec.js | 6 +- webapp/channels/package.json | 2 +- .../announcement_bar.tsx | 2 + .../default_announcement_bar.scss | 5 + .../channel_header/channel_header.tsx | 9 +- .../src/components/common/comment_icon.tsx | 4 +- .../src/components/overlay_trigger.test.tsx | 179 --------------- .../post_flag_icon.test.tsx.snap | 12 +- .../post_flag_icon/post_flag_icon.tsx | 4 +- .../__snapshots__/post_reaction.test.tsx.snap | 6 +- .../post_view/post_reaction/post_reaction.tsx | 4 +- .../post_recent_reactions.tsx | 18 +- .../__snapshots__/reaction.test.tsx.snap | 4 - .../post_view/reaction/reaction.tsx | 1 - .../reaction_tooltip/reaction_tooltip.tsx | 22 +- .../profile_popover_controller.tsx | 2 +- .../team_sidebar/components/team_button.tsx | 8 +- .../user_group_popover_controller.tsx | 2 +- .../with_tooltip/create_tooltip.tsx | 8 +- .../src/components/with_tooltip/index.tsx | 4 +- .../{ => with_tooltip}/overlay_trigger.tsx | 2 +- .../components/{ => with_tooltip}/tooltip.tsx | 0 .../tooltip_content.test.tsx.snap | 140 ++++++++++++ .../with_tooltip_new/index.test.tsx | 97 +++++++++ .../with_tooltip/with_tooltip_new/index.tsx | 204 ++++++++++++++++++ .../with_tooltip_new/tooltip.scss | 70 ++++++ .../with_tooltip_new/tooltip_content.test.tsx | 68 ++++++ .../with_tooltip_new/tooltip_content.tsx | 65 ++++++ .../tooltip_shortcut.test.tsx | 74 +++++++ .../with_tooltip_new/tooltip_shortcut.tsx | 56 +++++ .../src/sass/components/_tooltip.scss | 36 ---- webapp/package-lock.json | 2 +- 32 files changed, 829 insertions(+), 287 deletions(-) create mode 100644 webapp/channels/src/components/announcement_bar/default_announcement_bar/default_announcement_bar.scss delete mode 100644 webapp/channels/src/components/overlay_trigger.test.tsx rename webapp/channels/src/components/{ => with_tooltip}/overlay_trigger.tsx (96%) rename webapp/channels/src/components/{ => with_tooltip}/tooltip.tsx (100%) create mode 100644 webapp/channels/src/components/with_tooltip/with_tooltip_new/__snapshots__/tooltip_content.test.tsx.snap create mode 100644 webapp/channels/src/components/with_tooltip/with_tooltip_new/index.test.tsx create mode 100644 webapp/channels/src/components/with_tooltip/with_tooltip_new/index.tsx create mode 100644 webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip.scss create mode 100644 webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_content.test.tsx create mode 100644 webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_content.tsx create mode 100644 webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_shortcut.test.tsx create mode 100644 webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_shortcut.tsx diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/tooltip_visual_verification_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/tooltip_visual_verification_spec.js index 30bbdf260a3..489e59e3ee7 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/tooltip_visual_verification_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/tooltip_visual_verification_spec.js @@ -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'); } }); diff --git a/webapp/channels/package.json b/webapp/channels/package.json index f10c0572772..1c4dd860ecb 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -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", diff --git a/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx b/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx index bcf45df5506..fbfa70df0b1 100644 --- a/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx +++ b/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx @@ -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; diff --git a/webapp/channels/src/components/announcement_bar/default_announcement_bar/default_announcement_bar.scss b/webapp/channels/src/components/announcement_bar/default_announcement_bar/default_announcement_bar.scss new file mode 100644 index 00000000000..f3790e673df --- /dev/null +++ b/webapp/channels/src/components/announcement_bar/default_announcement_bar/default_announcement_bar.scss @@ -0,0 +1,5 @@ +#announcement-bar__tooltip { + width: 50%; + max-width: 100%; + pointer-events: auto; +} diff --git a/webapp/channels/src/components/channel_header/channel_header.tsx b/webapp/channels/src/components/channel_header/channel_header.tsx index a5e2ba232d2..ca94f2453f5 100644 --- a/webapp/channels/src/components/channel_header/channel_header.tsx +++ b/webapp/channels/src/components/channel_header/channel_header.tsx @@ -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 { toggleFavoriteRef: RefObject; headerDescriptionRef: RefObject; headerPopoverTextMeasurerRef: RefObject; - headerOverlayRef: RefObject; + headerOverlayRef: RefObject; getHeaderMarkdownOptions: (channelNamesMap: Record) => Record; getPopoverMarkdownOptions: (channelNamesMap: Record) => Record; diff --git a/webapp/channels/src/components/common/comment_icon.tsx b/webapp/channels/src/components/common/comment_icon.tsx index 1ccc50af37a..71e8b3afa88 100644 --- a/webapp/channels/src/components/common/comment_icon.tsx +++ b/webapp/channels/src/components/common/comment_icon.tsx @@ -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 ( { - const testId = 'test.value'; - - let store: Store; - - const intlProviderProps = { - defaultLocale: 'en', - locale: 'en', - messages: { - [testId]: 'Actual value', - }, - }; - const baseProps = { - overlay: ( - - ), - }; - - // 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( - - - - - - - , - ); - - // 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. needs to exist in the component ancestry.'); - }); - - test('custom OverlayTrigger should pass intl to overlay', () => { - const wrapper = mount( - - - - - - - , - ); - - 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(); - const props = { - ...baseProps, - ref, - }; - - const wrapper = mountWithIntl( - - - - - - - , - ); - - expect(ref.current).toBe(wrapper.find(BaseOverlayTrigger).instance()); - }); - - test('style and className should correctly be passed to overlay', () => { - const props = { - ...baseProps, - overlay: ( - - {'test-overlay'} - - ), - defaultOverlayShown: true, // Make sure the overlay is visible - }; - - const wrapper = mount( - - - - - - - , - ); - - // 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: ( - - {'test-overlay'} - - ), - defaultOverlayShown: true, // Make sure the overlay is visible - disabled: true, - }; - - const wrapper = mount( - - - - - - - , - ); - - // 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'}); - }); -}); diff --git a/webapp/channels/src/components/post_view/post_flag_icon/__snapshots__/post_flag_icon.test.tsx.snap b/webapp/channels/src/components/post_view/post_flag_icon/__snapshots__/post_flag_icon.test.tsx.snap index 049996f8110..7f5e9329fc2 100644 --- a/webapp/channels/src/components/post_view/post_flag_icon/__snapshots__/post_flag_icon.test.tsx.snap +++ b/webapp/channels/src/components/post_view/post_flag_icon/__snapshots__/post_flag_icon.test.tsx.snap @@ -1,10 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`components/post_view/PostFlagIcon should match snapshot 1`] = ` - - + `; exports[`components/post_view/PostFlagIcon should match snapshot 2`] = ` - - + `; diff --git a/webapp/channels/src/components/post_view/post_flag_icon/post_flag_icon.tsx b/webapp/channels/src/components/post_view/post_flag_icon/post_flag_icon.tsx index b60f02553b0..0ccc6da9739 100644 --- a/webapp/channels/src/components/post_view/post_flag_icon/post_flag_icon.tsx +++ b/webapp/channels/src/components/post_view/post_flag_icon/post_flag_icon.tsx @@ -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 ( - - + `; diff --git a/webapp/channels/src/components/post_view/post_reaction/post_reaction.tsx b/webapp/channels/src/components/post_view/post_reaction/post_reaction.tsx index a3a1f7e1dfb..7d8a7fbaadc 100644 --- a/webapp/channels/src/components/post_view/post_reaction/post_reaction.tsx +++ b/webapp/channels/src/components/post_view/post_reaction/post_reaction.tsx @@ -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 { spaceRequiredBelow={spaceRequiredBelow} /> + , + ); + + expect(screen.getByText('I am a button surrounded by a tooltip')).toBeInTheDocument(); + }); + + test('shows tooltip on hover', async () => { + jest.useFakeTimers(); + + renderWithContext( + +
{'Hover Me'}
+
, + ); + + 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( + + + , + ); + + 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( + +
{'Hover Me'}
+
, + ); + + 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(); + }); + }); + }); +}); diff --git a/webapp/channels/src/components/with_tooltip/with_tooltip_new/index.tsx b/webapp/channels/src/components/with_tooltip/with_tooltip_new/index.tsx new file mode 100644 index 00000000000..8db8fa98c08 --- /dev/null +++ b/webapp/channels/src/components/with_tooltip/with_tooltip_new/index.tsx @@ -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 && ( + +
+
+ + +
+
+
+ )} + + ); +} + +export default memo(WithTooltip); diff --git a/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip.scss b/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip.scss new file mode 100644 index 00000000000..5508d1f9711 --- /dev/null +++ b/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip.scss @@ -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; + } + } + } +} diff --git a/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_content.test.tsx b/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_content.test.tsx new file mode 100644 index 00000000000..dbb2e8a979f --- /dev/null +++ b/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_content.test.tsx @@ -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( + , + ); + + expect(container).toMatchSnapshot(); + }); + + test('have correct structure with title and emoji', () => { + const {container} = renderWithContext( + , + ); + + expect(container).toMatchSnapshot(); + }); + + test('have correct structure with title and large emoji', () => { + const {container} = renderWithContext( + , + ); + + expect(container).toMatchSnapshot(); + }); + + test('have correct structure with title, emoji and hint', () => { + const {container} = renderWithContext( + , + ); + + expect(container).toMatchSnapshot(); + }); + + test('have correct structure with title and shortcut', () => { + const shortcut = { + default: ['Ctrl', 'K'], + }; + + const {container} = renderWithContext( + , + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_content.tsx b/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_content.tsx new file mode 100644 index 00000000000..35c4552aa55 --- /dev/null +++ b/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_content.tsx @@ -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 ( +
+ + {props.emoji && ( + + + + )} + {title} + + {props.hint && ( + {props.hint} + )} + {props.shortcut && ( + + + + )} +
+ ); +} + +export default memo(TooltipContent); diff --git a/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_shortcut.test.tsx b/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_shortcut.test.tsx new file mode 100644 index 00000000000..94977feb8f6 --- /dev/null +++ b/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_shortcut.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.getByText('Enter')).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_shortcut.tsx b/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_shortcut.tsx new file mode 100644 index 00000000000..e18de694954 --- /dev/null +++ b/webapp/channels/src/components/with_tooltip/with_tooltip_new/tooltip_shortcut.tsx @@ -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 = ; + } else { + key = shortcutKey; + content = shortcutKey; + } + + return ( + + {content} + + ); + })} + + ); +} + +export default memo(TooltipShortcut); diff --git a/webapp/channels/src/sass/components/_tooltip.scss b/webapp/channels/src/sass/components/_tooltip.scss index 37c66fdd5c2..c4c68a71e99 100644 --- a/webapp/channels/src/sass/components/_tooltip.scss +++ b/webapp/channels/src/sass/components/_tooltip.scss @@ -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); -} diff --git a/webapp/package-lock.json b/webapp/package-lock.json index d995573f413..1878f826c24 100644 --- a/webapp/package-lock.json +++ b/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",