mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
MM-62383 Replace React Bootstrap with Floating UI in Emoji Picker (#29835)
* Convert EmojiPickerOverlay to functional component * Convert EmojiPickerTabs to functional component * Extract AddReactionButton from ReactionList This is so that I can make part of it functional without rewriting the whole thing. * Convert PostReaction to functional component * Add general version of useEmojiPicker and use for AddReactionButton * Rename returned showEmojiPicker to emojiPickerOpen * Add test for AddReactionButton * Move showEmojiPicker state out of useEmojiPicker I hoped to avoid this by just having the hook return the show state, but unfortunately, too many of the existing places rely on controlling the state themselves * Change PostReaction to use useEmojiPicker I ran into some trouble with this getting the hover state to properly disappear from the PostComponent when clicking out of the picker. That seems to be a downside of the browser's mouseenter/mouseleave not handling cases where the component is covered up. It doesn't work 100%, but it works at least as well as master by disabling pointer-events to the FloatingOverlay (which I also think we could probably remove since it's supposed to just be for darkening the backdrop behind the picker, but it ended up being helpful for setting the z-index on mobile). * Change AdvancedTextEditor to use new useEmojiPicker I renamed its version of useEmojiPicker to useEditorEmojiPicker since it still contains information about how to position the emoji or gifs in the post text. * Convert EditPost to use useEmojiPicker * Convert CreateModalNameInput to use useEmoijPicker * Convert CustomStatusModal to use useEmojiPicker * Remove EmojiPickerOverlay and cleanup related code * Remove unneeded translation string * asdf Attempting to fix E2E test * Improve how useEmojiPicker positions itself to stay on screen more * Add offset between Emoji Picker and reference * Add horizontallyWithin middleware and use it to right-align the emoji picker in the post textbox
This commit is contained in:
parent
da7192246e
commit
b6118b7701
@ -193,7 +193,7 @@ describe('Keyboard shortcut CTRL/CMD+Shift+\\ for adding reaction to last messag
|
||||
cy.get('#emojiPicker').should('exist');
|
||||
|
||||
// # Click anywhere to close emoji picker
|
||||
cy.get('#channelHeaderInfo').click();
|
||||
cy.get('body').click();
|
||||
cy.get('#emojiPicker').should('not.exist');
|
||||
});
|
||||
|
||||
|
@ -64,6 +64,8 @@ describe('Edit Message', () => {
|
||||
// * Assert channel autocomplete is not visible
|
||||
cy.get('#suggestionList').should('not.exist');
|
||||
|
||||
cy.wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
// # In the modal click the emoji picker icon
|
||||
cy.get('div.post-edit__container button#emojiPickerButton').click();
|
||||
|
||||
|
@ -77,7 +77,7 @@ import SendButton from './send_button';
|
||||
import ShowFormat from './show_formatting';
|
||||
import TexteditorActions from './texteditor_actions';
|
||||
import ToggleFormattingBar from './toggle_formatting_bar';
|
||||
import useEmojiPicker from './use_emoji_picker';
|
||||
import useEditorEmojiPicker from './use_editor_emoji_picker';
|
||||
import useKeyHandler from './use_key_handler';
|
||||
import useOrientationHandler from './use_orientation_handler';
|
||||
import usePluginItems from './use_plugin_items';
|
||||
@ -132,6 +132,24 @@ const AdvancedTextEditor = ({
|
||||
const getDraftSelector = useMemo(makeGetDraft, []);
|
||||
const getDisplayName = useMemo(makeGetDisplayName, []);
|
||||
|
||||
let textboxId = 'textbox';
|
||||
|
||||
switch (location) {
|
||||
case Locations.CENTER:
|
||||
textboxId = 'post_textbox';
|
||||
break;
|
||||
case Locations.RHS_COMMENT:
|
||||
textboxId = 'reply_textbox';
|
||||
break;
|
||||
case Locations.MODAL:
|
||||
textboxId = 'modal_textbox';
|
||||
break;
|
||||
}
|
||||
|
||||
if (isInEditMode) {
|
||||
textboxId = 'edit_textbox';
|
||||
}
|
||||
|
||||
const isRHS = Boolean(postId && !isThreadView);
|
||||
|
||||
const getFormattingBarPreferenceName = () => {
|
||||
@ -310,12 +328,20 @@ const AdvancedTextEditor = ({
|
||||
isInEditMode,
|
||||
);
|
||||
|
||||
const emojiPickerOffset = isInEditMode ? {right: 40} : undefined;
|
||||
const {
|
||||
emojiPicker,
|
||||
enableEmojiPicker,
|
||||
toggleEmojiPicker,
|
||||
} = useEmojiPicker(isDisabled, draft, caretPosition, setCaretPosition, handleDraftChange, showPreview, focusTextbox, emojiPickerOffset);
|
||||
} = useEditorEmojiPicker(
|
||||
textboxId,
|
||||
isDisabled,
|
||||
draft,
|
||||
caretPosition,
|
||||
setCaretPosition,
|
||||
handleDraftChange,
|
||||
showPreview,
|
||||
focusTextbox,
|
||||
);
|
||||
const {
|
||||
labels: priorityLabels,
|
||||
additionalControl: priorityAdditionalControl,
|
||||
@ -653,24 +679,6 @@ const AdvancedTextEditor = ({
|
||||
|
||||
const messageValue = isDisabled ? '' : draft.message_source || draft.message;
|
||||
|
||||
let textboxId = 'textbox';
|
||||
|
||||
switch (location) {
|
||||
case Locations.CENTER:
|
||||
textboxId = 'post_textbox';
|
||||
break;
|
||||
case Locations.RHS_COMMENT:
|
||||
textboxId = 'reply_textbox';
|
||||
break;
|
||||
case Locations.MODAL:
|
||||
textboxId = 'modal_textbox';
|
||||
break;
|
||||
}
|
||||
|
||||
if (isInEditMode) {
|
||||
textboxId = 'edit_textbox';
|
||||
}
|
||||
|
||||
const wasNotifiedOfLogIn = LocalStorageStore.getWasNotifiedOfLogIn();
|
||||
|
||||
let loginSuccessfulLabel;
|
||||
|
@ -1,8 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {flip, offset, shift} from '@floating-ui/react';
|
||||
import classNames from 'classnames';
|
||||
import React, {useCallback, useRef, useState} from 'react';
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
@ -13,10 +14,11 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getEmojiName} from 'mattermost-redux/utils/emoji_utils';
|
||||
|
||||
import useDidUpdate from 'components/common/hooks/useDidUpdate';
|
||||
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
|
||||
import useEmojiPicker, {useEmojiPickerOffset} from 'components/emoji_picker/use_emoji_picker';
|
||||
import KeyboardShortcutSequence, {KEYBOARD_SHORTCUTS} from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence';
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
import {horizontallyWithin} from 'utils/floating';
|
||||
import {splitMessageBasedOnCaretPosition} from 'utils/post_utils';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
@ -24,7 +26,8 @@ import type {PostDraft} from 'types/store/draft';
|
||||
|
||||
import {IconContainer} from './formatting_bar/formatting_icon';
|
||||
|
||||
const useEmojiPicker = (
|
||||
const useEditorEmojiPicker = (
|
||||
textboxId: string,
|
||||
isDisabled: boolean,
|
||||
draft: PostDraft,
|
||||
caretPosition: number,
|
||||
@ -32,15 +35,12 @@ const useEmojiPicker = (
|
||||
handleDraftChange: (draft: PostDraft) => void,
|
||||
shouldShowPreview: boolean,
|
||||
focusTextbox: () => void,
|
||||
emojiPickerOffset?: {right?: number},
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const enableEmojiPicker = useSelector((state: GlobalState) => getConfig(state).EnableEmojiPicker === 'true');
|
||||
const enableGifPicker = useSelector((state: GlobalState) => getConfig(state).EnableGifPicker === 'true');
|
||||
|
||||
const emojiPickerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
|
||||
const toggleEmojiPicker = useCallback((e?: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
|
||||
@ -48,14 +48,6 @@ const useEmojiPicker = (
|
||||
setShowEmojiPicker((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const hideEmojiPicker = useCallback(() => {
|
||||
setShowEmojiPicker(false);
|
||||
}, []);
|
||||
|
||||
const getEmojiPickerRef = useCallback(() => {
|
||||
return emojiPickerRef.current;
|
||||
}, []);
|
||||
|
||||
const handleEmojiClick = useCallback((emoji: Emoji) => {
|
||||
const emojiAlias = getEmojiName(emoji);
|
||||
|
||||
@ -111,25 +103,41 @@ const useEmojiPicker = (
|
||||
// Focus textbox when the emoji picker closes
|
||||
useDidUpdate(() => {
|
||||
if (!showEmojiPicker) {
|
||||
focusTextbox();
|
||||
// Wait a frame to let the emoji picker's focus trap disappear before changing focus
|
||||
requestAnimationFrame(() => {
|
||||
focusTextbox();
|
||||
});
|
||||
}
|
||||
}, [showEmojiPicker]);
|
||||
|
||||
let emojiPicker = null;
|
||||
const {
|
||||
emojiPicker,
|
||||
getReferenceProps,
|
||||
setReference,
|
||||
} = useEmojiPicker({
|
||||
showEmojiPicker,
|
||||
setShowEmojiPicker,
|
||||
|
||||
enableGifPicker,
|
||||
onGifClick: handleGifClick,
|
||||
onEmojiClick: handleEmojiClick,
|
||||
|
||||
overrideMiddleware: [
|
||||
offset(useEmojiPickerOffset),
|
||||
shift(),
|
||||
horizontallyWithin({
|
||||
boundary: document.getElementById(textboxId),
|
||||
}),
|
||||
flip({
|
||||
fallbackAxisSideDirection: 'end',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
let emojiPickerControls = null;
|
||||
if (enableEmojiPicker && !isDisabled) {
|
||||
emojiPicker = (
|
||||
emojiPickerControls = (
|
||||
<>
|
||||
<EmojiPickerOverlay
|
||||
show={showEmojiPicker}
|
||||
target={getEmojiPickerRef}
|
||||
onHide={hideEmojiPicker}
|
||||
onEmojiClick={handleEmojiClick}
|
||||
onGifClick={handleGifClick}
|
||||
enableGifPicker={enableGifPicker}
|
||||
topOffset={-7}
|
||||
rightOffset={emojiPickerOffset?.right}
|
||||
/>
|
||||
<WithTooltip
|
||||
title={
|
||||
<KeyboardShortcutSequence
|
||||
@ -141,12 +149,13 @@ const useEmojiPicker = (
|
||||
>
|
||||
<IconContainer
|
||||
id={'emojiPickerButton'}
|
||||
ref={emojiPickerRef}
|
||||
ref={setReference}
|
||||
onClick={toggleEmojiPicker}
|
||||
type='button'
|
||||
aria-label={intl.formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
|
||||
disabled={shouldShowPreview}
|
||||
className={classNames({active: showEmojiPicker})}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
<EmoticonHappyOutlineIcon
|
||||
color={'currentColor'}
|
||||
@ -154,11 +163,12 @@ const useEmojiPicker = (
|
||||
/>
|
||||
</IconContainer>
|
||||
</WithTooltip>
|
||||
{emojiPicker}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return {emojiPicker, enableEmojiPicker, toggleEmojiPicker};
|
||||
return {emojiPicker: emojiPickerControls, enableEmojiPicker, toggleEmojiPicker};
|
||||
};
|
||||
|
||||
export default useEmojiPicker;
|
||||
export default useEditorEmojiPicker;
|
@ -11,7 +11,7 @@ import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks';
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
import type {FileInfo} from '@mattermost/types/files';
|
||||
|
||||
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
|
||||
import useEmojiPicker from 'components/emoji_picker/use_emoji_picker';
|
||||
import Input from 'components/widgets/inputs/input/input';
|
||||
|
||||
import Constants, {A11yCustomEventTypes, type A11yFocusEventDetail} from 'utils/constants';
|
||||
@ -50,7 +50,6 @@ const CreateModalNameInput = ({
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const targetRef = useRef<HTMLButtonElement>(null);
|
||||
const getTargetRef = () => targetRef.current;
|
||||
|
||||
const icon = (
|
||||
<BookmarkIcon
|
||||
@ -90,11 +89,6 @@ const CreateModalNameInput = ({
|
||||
setEmoji('');
|
||||
};
|
||||
|
||||
const handleEmojiClose = () => {
|
||||
setShowEmojiPicker(false);
|
||||
refocusEmojiButton();
|
||||
};
|
||||
|
||||
const handleInputChange: ComponentProps<typeof Input>['onChange'] = useCallback((e) => {
|
||||
setDisplayName(e.currentTarget.value);
|
||||
}, []);
|
||||
@ -112,33 +106,34 @@ const CreateModalNameInput = ({
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
emojiPicker,
|
||||
getReferenceProps,
|
||||
setReference,
|
||||
} = useEmojiPicker({
|
||||
showEmojiPicker,
|
||||
setShowEmojiPicker,
|
||||
|
||||
onAddCustomEmojiClick,
|
||||
onEmojiClick: handleEmojiClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<NameWrapper>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPickerOverlay
|
||||
target={getTargetRef}
|
||||
show={showEmojiPicker}
|
||||
onHide={handleEmojiClose}
|
||||
onEmojiClick={handleEmojiClick}
|
||||
placement='right'
|
||||
onAddCustomEmojiClick={onAddCustomEmojiClick}
|
||||
/>
|
||||
|
||||
)}
|
||||
<button
|
||||
ref={targetRef}
|
||||
ref={setReference}
|
||||
type='button'
|
||||
onClick={toggleEmojiPicker}
|
||||
onKeyDown={handleEmojiKeyDown}
|
||||
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
|
||||
aria-expanded={showEmojiPicker ? 'true' : 'false'}
|
||||
|
||||
className='channelBookmarksMenuButton emoji-picker__container BookmarkCreateModal__emoji-button'
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{icon}
|
||||
<ChevronDownIcon size={'12px'}/>
|
||||
</button>
|
||||
{emojiPicker}
|
||||
<Input
|
||||
maxLength={maxLength}
|
||||
type='text'
|
||||
|
@ -4,7 +4,7 @@
|
||||
import classNames from 'classnames';
|
||||
import type {Moment} from 'moment-timezone';
|
||||
import moment from 'moment-timezone';
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import type {MessageDescriptor} from 'react-intl';
|
||||
import {FormattedMessage, defineMessage, useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
@ -28,12 +28,11 @@ import CustomStatusSuggestion from 'components/custom_status/custom_status_sugge
|
||||
import DateTimeInput, {getRoundedTime} from 'components/custom_status/date_time_input';
|
||||
import ExpiryMenu from 'components/custom_status/expiry_menu';
|
||||
import RenderEmoji from 'components/emoji/render_emoji';
|
||||
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
|
||||
import useEmojiPicker from 'components/emoji_picker/use_emoji_picker';
|
||||
import QuickInput, {MaxLengthInput} from 'components/quick_input';
|
||||
import EmojiIcon from 'components/widgets/icons/emoji_icon';
|
||||
|
||||
import {A11yCustomEventTypes, Constants, ModalIdentifiers} from 'utils/constants';
|
||||
import type {A11yFocusEventDetail} from 'utils/constants';
|
||||
import {Constants, ModalIdentifiers} from 'utils/constants';
|
||||
import {isKeyPressed} from 'utils/keyboard';
|
||||
import {getCurrentMomentForTimezone} from 'utils/timezone';
|
||||
|
||||
@ -49,7 +48,6 @@ type Props = {
|
||||
// This is the same limit set
|
||||
// https://github.com/mattermost/mattermost-server/pull/16835/files#diff-73c61af5954b16f5e3cb5ee786af9eb698f660eff0d65db5556949be5fb6e60bR15
|
||||
const CUSTOM_STATUS_TEXT_CHARACTER_LIMIT = 100;
|
||||
const EMOJI_PICKER_WIDTH_OFFSET = 308;
|
||||
|
||||
type DefaultUserCustomStatus = {
|
||||
emoji: string;
|
||||
@ -118,8 +116,6 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
|
||||
const currentCustomStatus = useSelector(getCustomStatus);
|
||||
const customStatusExpired = useSelector((state: GlobalState) => isCustomStatusExpired(state, currentCustomStatus));
|
||||
const recentCustomStatuses = useSelector(getRecentCustomStatuses);
|
||||
const customStatusControlRef = useRef<HTMLDivElement>(null);
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const {formatMessage} = useIntl();
|
||||
const isCurrentCustomStatusSet = !customStatusExpired && (currentCustomStatus?.text || currentCustomStatus?.emoji);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
@ -230,54 +226,10 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
|
||||
|
||||
const handleClearStatus = isCurrentCustomStatusSet ? () => dispatch(unsetCustomStatus()) : undefined;
|
||||
|
||||
const getCustomStatusControlRef = () => customStatusControlRef.current;
|
||||
|
||||
const handleEmojiClose = () => {
|
||||
setShowEmojiPicker(false);
|
||||
if (emojiButtonRef.current) {
|
||||
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
|
||||
A11yCustomEventTypes.FOCUS, {
|
||||
detail: {
|
||||
target: emojiButtonRef.current as HTMLElement,
|
||||
keyboardOnly: true,
|
||||
},
|
||||
},
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiExited = () => {
|
||||
if (emojiButtonRef.current) {
|
||||
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
|
||||
A11yCustomEventTypes.FOCUS, {
|
||||
detail: {
|
||||
target: emojiButtonRef.current as HTMLElement,
|
||||
keyboardOnly: true,
|
||||
},
|
||||
},
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiClick = (selectedEmoji: Emoji) => {
|
||||
setShowEmojiPicker(false);
|
||||
const emojiName = ('short_name' in selectedEmoji) ? selectedEmoji.short_name : selectedEmoji.name;
|
||||
setEmoji(emojiName);
|
||||
if (emojiButtonRef.current) {
|
||||
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
|
||||
A11yCustomEventTypes.FOCUS, {
|
||||
detail: {
|
||||
target: emojiButtonRef.current as HTMLElement,
|
||||
keyboardOnly: true,
|
||||
},
|
||||
},
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEmojiPicker = (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
|
||||
e?.stopPropagation();
|
||||
setShowEmojiPicker((prevShow) => !prevShow);
|
||||
};
|
||||
|
||||
const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => setText(event.target.value);
|
||||
@ -291,6 +243,17 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
|
||||
/>
|
||||
) : <EmojiIcon className={'icon icon--emoji'}/>;
|
||||
|
||||
const {
|
||||
emojiPicker,
|
||||
getReferenceProps,
|
||||
setReference,
|
||||
} = useEmojiPicker({
|
||||
showEmojiPicker,
|
||||
setShowEmojiPicker,
|
||||
|
||||
onEmojiClick: handleEmojiClick,
|
||||
});
|
||||
|
||||
const clearHandle = () => {
|
||||
setEmoji('');
|
||||
setText('');
|
||||
@ -303,19 +266,6 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
|
||||
setDuration(status.duration || DONT_CLEAR);
|
||||
};
|
||||
|
||||
const calculateRightOffSet = () => {
|
||||
let rightOffset = Constants.DEFAULT_EMOJI_PICKER_RIGHT_OFFSET;
|
||||
const target = getCustomStatusControlRef();
|
||||
if (target) {
|
||||
rightOffset = window.innerWidth - target.getBoundingClientRect().left - EMOJI_PICKER_WIDTH_OFFSET;
|
||||
if (rightOffset < 0) {
|
||||
rightOffset = Constants.DEFAULT_EMOJI_PICKER_RIGHT_OFFSET;
|
||||
}
|
||||
}
|
||||
|
||||
return rightOffset;
|
||||
};
|
||||
|
||||
const recentStatuses = (
|
||||
<div id='statusSuggestion__recents'>
|
||||
<div className='statusSuggestion__title'>
|
||||
@ -425,34 +375,19 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
|
||||
>
|
||||
<div className='StatusModal__body'>
|
||||
<div className='StatusModal__input'>
|
||||
<div
|
||||
ref={customStatusControlRef}
|
||||
className='StatusModal__emoji-container'
|
||||
>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPickerOverlay
|
||||
target={getCustomStatusControlRef}
|
||||
show={showEmojiPicker}
|
||||
onHide={handleEmojiClose}
|
||||
onEmojiClick={handleEmojiClick}
|
||||
rightOffset={calculateRightOffSet()}
|
||||
leftOffset={3}
|
||||
topOffset={3}
|
||||
defaultHorizontalPosition='right'
|
||||
onExited={handleEmojiExited}
|
||||
/>
|
||||
)}
|
||||
<div className='StatusModal__emoji-container'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={toggleEmojiPicker}
|
||||
ref={emojiButtonRef}
|
||||
ref={setReference}
|
||||
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
|
||||
className={classNames('emoji-picker__container', 'StatusModal__emoji-button', {
|
||||
'StatusModal__emoji-button--active': showEmojiPicker,
|
||||
})}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{customStatusEmoji}
|
||||
</button>
|
||||
{emojiPicker}
|
||||
</div>
|
||||
<QuickInput
|
||||
inputComponent={MaxLengthInput}
|
||||
|
@ -67,7 +67,7 @@ type Props = {
|
||||
isFlagged?: boolean;
|
||||
handleCommentClick?: React.EventHandler<any>;
|
||||
handleDropdownOpened: (open: boolean) => void;
|
||||
handleAddReactionClick?: () => void;
|
||||
handleAddReactionClick?: (showEmojiPicker: boolean) => void;
|
||||
isMenuOpen?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isLicensed?: boolean; // TechDebt: Made non-mandatory while converting to typescript
|
||||
@ -209,9 +209,8 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
handleAddReactionMenuItemActivated = () => {
|
||||
// to be safe, make sure the handler function has been defined
|
||||
if (this.props.handleAddReactionClick) {
|
||||
this.props.handleAddReactionClick();
|
||||
this.props.handleAddReactionClick(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -44,14 +44,6 @@ import DotMenu from './dot_menu';
|
||||
|
||||
type Props = {
|
||||
post: Post;
|
||||
isFlagged?: boolean;
|
||||
handleCommentClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>;
|
||||
handleCardClick?: (post: Post) => void;
|
||||
handleDropdownOpened: (open: boolean) => void;
|
||||
handleAddReactionClick?: () => void;
|
||||
isMenuOpen: boolean;
|
||||
isReadOnly?: boolean;
|
||||
enableEmojiPicker?: boolean;
|
||||
location?: ComponentProps<typeof DotMenu>['location'];
|
||||
};
|
||||
|
||||
|
@ -20,9 +20,8 @@ import {openModal} from 'actions/views/modals';
|
||||
import {getConnectionId} from 'selectors/general';
|
||||
|
||||
import DeletePostModal from 'components/delete_post_modal';
|
||||
import DeleteScheduledPostModal
|
||||
from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal';
|
||||
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
|
||||
import DeleteScheduledPostModal from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal';
|
||||
import useEmojiPicker from 'components/emoji_picker/use_emoji_picker';
|
||||
import Textbox from 'components/textbox';
|
||||
import type {TextboxClass, TextboxElement} from 'components/textbox';
|
||||
|
||||
@ -102,9 +101,6 @@ export type State = {
|
||||
|
||||
const {KeyCodes} = Constants;
|
||||
|
||||
const TOP_OFFSET = 0;
|
||||
const RIGHT_OFFSET = 10;
|
||||
|
||||
const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, scheduledPost, afterSave, onCancel, onDeleteScheduledPost, ...rest}: Props): JSX.Element | null => {
|
||||
const connectionId = useSelector(getConnectionId);
|
||||
const channel = useSelector((state: GlobalState) => getChannel(state, channelId));
|
||||
@ -123,7 +119,6 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
|
||||
const [showMentionHelper, setShowMentionHelper] = useState<boolean>(false);
|
||||
|
||||
const textboxRef = useRef<TextboxClass>(null);
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// using a ref here makes sure that the unmounting callback (saveDraft) is fired with the correct value.
|
||||
@ -503,11 +498,6 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
|
||||
}
|
||||
};
|
||||
|
||||
const hideEmojiPicker = () => {
|
||||
setShowEmojiPicker(false);
|
||||
textboxRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleEmojiClick = (emoji?: Emoji) => {
|
||||
if (!emoji) {
|
||||
return;
|
||||
@ -570,35 +560,37 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
|
||||
}
|
||||
};
|
||||
|
||||
const getEmojiTargetRef = useCallback(() => emojiButtonRef.current, [emojiButtonRef]);
|
||||
const {
|
||||
emojiPicker,
|
||||
getReferenceProps,
|
||||
setReference,
|
||||
} = useEmojiPicker({
|
||||
showEmojiPicker,
|
||||
setShowEmojiPicker,
|
||||
|
||||
let emojiPicker = null;
|
||||
enableGifPicker: config.EnableGifPicker === 'true',
|
||||
onGifClick: handleGifClick,
|
||||
onEmojiClick: handleEmojiClick,
|
||||
});
|
||||
|
||||
let emojiPickerControls = null;
|
||||
if (config.EnableEmojiPicker === 'true') {
|
||||
emojiPicker = (
|
||||
emojiPickerControls = (
|
||||
<>
|
||||
<EmojiPickerOverlay
|
||||
show={showEmojiPicker}
|
||||
target={getEmojiTargetRef}
|
||||
onHide={hideEmojiPicker}
|
||||
onEmojiClick={handleEmojiClick}
|
||||
onGifClick={handleGifClick}
|
||||
enableGifPicker={config.EnableGifPicker === 'true'}
|
||||
topOffset={TOP_OFFSET}
|
||||
rightOffset={RIGHT_OFFSET}
|
||||
/>
|
||||
<button
|
||||
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
|
||||
id='editPostEmoji'
|
||||
ref={emojiButtonRef}
|
||||
ref={setReference}
|
||||
className='style--none post-action'
|
||||
onClick={toggleEmojiPicker}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
<EmoticonPlusOutlineIcon
|
||||
size={18}
|
||||
color='currentColor'
|
||||
/>
|
||||
</button>
|
||||
{emojiPicker}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -637,7 +629,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
|
||||
useChannelMentions={rest.useChannelMentions}
|
||||
/>
|
||||
<div className='post-body__actions'>
|
||||
{emojiPicker}
|
||||
{emojiPickerControls}
|
||||
</div>
|
||||
{ showMentionHelper ? (
|
||||
<div className='post-body__info'>
|
||||
|
@ -27,7 +27,7 @@ exports[`components/emoji_picker/EmojiPicker should match snapshot 1`] = `
|
||||
class="emoji-picker__search"
|
||||
data-testid="emojiInputSearch"
|
||||
id="emojiPickerSearch"
|
||||
placeholder="Search Emoji"
|
||||
placeholder="Search emojis"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
|
@ -138,7 +138,7 @@ const EmojiPickerSearch = forwardRef<HTMLInputElement, Props>(({value, cursorCat
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete='off'
|
||||
placeholder={formatMessage({id: 'emoji_picker.search', defaultMessage: 'Search Emoji'})}
|
||||
placeholder={formatMessage({id: 'emoji_picker.search', defaultMessage: 'Search emojis'})}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,113 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import memoize from 'memoize-one';
|
||||
import React from 'react';
|
||||
import type {ComponentProps, ReactNode} from 'react';
|
||||
import {Overlay} from 'react-bootstrap';
|
||||
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
|
||||
import {Constants} from 'utils/constants';
|
||||
import {popOverOverlayPosition} from 'utils/position_utils';
|
||||
|
||||
import EmojiPickerTabs from '../emoji_picker_tabs';
|
||||
|
||||
import type {PropsFromRedux} from './index';
|
||||
|
||||
export interface Props extends PropsFromRedux {
|
||||
target: () => ReactNode;
|
||||
onEmojiClick: (emoji: Emoji) => void;
|
||||
onGifClick?: (gif: string) => void;
|
||||
onAddCustomEmojiClick?: () => void;
|
||||
onHide: () => void;
|
||||
onExited?: () => void;
|
||||
show: boolean;
|
||||
placement?: ComponentProps<typeof Overlay>['placement'];
|
||||
topOffset?: number;
|
||||
rightOffset?: number;
|
||||
leftOffset?: number;
|
||||
spaceRequiredAbove?: number;
|
||||
spaceRequiredBelow?: number;
|
||||
enableGifPicker?: boolean;
|
||||
defaultHorizontalPosition?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export default class EmojiPickerOverlay extends React.PureComponent<Props> {
|
||||
// An emoji picker in the center channel is contained within the post list, so it needs space
|
||||
// above for the channel header and below for the post textbox
|
||||
static CENTER_SPACE_REQUIRED_ABOVE = 476;
|
||||
static CENTER_SPACE_REQUIRED_BELOW = 497;
|
||||
|
||||
// An emoji picker in the RHS isn't constrained by the RHS, so it just needs space to fit
|
||||
// the emoji picker itself
|
||||
static RHS_SPACE_REQUIRED_ABOVE = 420;
|
||||
static RHS_SPACE_REQUIRED_BELOW = 420;
|
||||
|
||||
// Reasonable defaults calculated from the center channel
|
||||
static defaultProps = {
|
||||
spaceRequiredAbove: EmojiPickerOverlay.CENTER_SPACE_REQUIRED_ABOVE,
|
||||
spaceRequiredBelow: EmojiPickerOverlay.CENTER_SPACE_REQUIRED_BELOW,
|
||||
enableGifPicker: false,
|
||||
};
|
||||
|
||||
emojiPickerPosition = memoize((emojiTrigger, show) => {
|
||||
let calculatedRightOffset = Constants.DEFAULT_EMOJI_PICKER_RIGHT_OFFSET;
|
||||
|
||||
if (!show) {
|
||||
return calculatedRightOffset;
|
||||
}
|
||||
|
||||
if (emojiTrigger) {
|
||||
calculatedRightOffset = window.innerWidth - emojiTrigger.getBoundingClientRect().left - Constants.DEFAULT_EMOJI_PICKER_LEFT_OFFSET;
|
||||
|
||||
if (calculatedRightOffset < Constants.DEFAULT_EMOJI_PICKER_RIGHT_OFFSET) {
|
||||
calculatedRightOffset = Constants.DEFAULT_EMOJI_PICKER_RIGHT_OFFSET;
|
||||
}
|
||||
}
|
||||
|
||||
return calculatedRightOffset;
|
||||
});
|
||||
|
||||
getPlacement = memoize((target, spaceRequiredAbove, spaceRequiredBelow, defaultHorizontalPosition, show) => {
|
||||
if (!show) {
|
||||
return 'top' as const;
|
||||
}
|
||||
|
||||
if (target) {
|
||||
const targetBounds = target.getBoundingClientRect();
|
||||
return popOverOverlayPosition(targetBounds, window.innerHeight, spaceRequiredAbove, spaceRequiredBelow, defaultHorizontalPosition);
|
||||
}
|
||||
|
||||
return 'top' as const;
|
||||
});
|
||||
|
||||
render() {
|
||||
const {target, rightOffset, spaceRequiredAbove, spaceRequiredBelow, defaultHorizontalPosition, show, isMobileView} = this.props;
|
||||
const calculatedRightOffset = typeof rightOffset === 'undefined' ? this.emojiPickerPosition(target(), show) : rightOffset;
|
||||
const placement = this.getPlacement(target(), spaceRequiredAbove, spaceRequiredBelow, defaultHorizontalPosition, show);
|
||||
|
||||
return (
|
||||
<Overlay
|
||||
show={show}
|
||||
placement={this.props.placement ?? placement}
|
||||
rootClose={!isMobileView}
|
||||
onHide={this.props.onHide}
|
||||
target={target}
|
||||
animation={false}
|
||||
onExited={this.props?.onExited}
|
||||
>
|
||||
<EmojiPickerTabs
|
||||
enableGifPicker={this.props.enableGifPicker}
|
||||
onEmojiClose={this.props.onHide}
|
||||
onEmojiClick={this.props.onEmojiClick}
|
||||
onGifClick={this.props.onGifClick}
|
||||
rightOffset={calculatedRightOffset}
|
||||
topOffset={this.props.topOffset}
|
||||
leftOffset={this.props.leftOffset}
|
||||
onAddCustomEmojiClick={this.props.onAddCustomEmojiClick}
|
||||
/>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import type {ConnectedProps} from 'react-redux';
|
||||
|
||||
import {getIsMobileView} from 'selectors/views/browser';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import EmojiPickerOverlay from './emoji_picker_overlay';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
return {
|
||||
isMobileView: getIsMobileView(state),
|
||||
};
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps);
|
||||
|
||||
export type PropsFromRedux = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(EmojiPickerOverlay);
|
@ -1,12 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import type {CSSProperties, RefObject} from 'react';
|
||||
import React, {PureComponent, createRef} from 'react';
|
||||
import React, {useCallback, useRef, useState} from 'react';
|
||||
import {Tab, Tabs} from 'react-bootstrap';
|
||||
import type {WrappedComponentProps} from 'react-intl';
|
||||
import {injectIntl, FormattedMessage} from 'react-intl';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
|
||||
@ -18,12 +15,7 @@ import GifIcon from 'components/widgets/icons/giphy_icon';
|
||||
|
||||
const GifPicker = makeAsyncComponent('GifPicker', React.lazy(() => import('components/gif_picker/gif_picker')));
|
||||
|
||||
export interface Props extends WrappedComponentProps {
|
||||
style?: CSSProperties;
|
||||
rightOffset?: number;
|
||||
topOffset?: number;
|
||||
leftOffset?: number;
|
||||
placement?: ('top' | 'bottom' | 'left' | 'right');
|
||||
export interface Props {
|
||||
onEmojiClose: () => void;
|
||||
onEmojiClick: (emoji: Emoji) => void;
|
||||
onGifClick?: (gif: string) => void;
|
||||
@ -31,173 +23,104 @@ export interface Props extends WrappedComponentProps {
|
||||
enableGifPicker?: boolean;
|
||||
}
|
||||
|
||||
type State = {
|
||||
emojiTabVisible: boolean;
|
||||
filter: string;
|
||||
activeKey: number;
|
||||
}
|
||||
export default function EmojiPickerTabs(props: Props) {
|
||||
const intl = useIntl();
|
||||
|
||||
class EmojiPickerTabs extends PureComponent<Props, State> {
|
||||
private rootPickerNodeRef: RefObject<HTMLDivElement>;
|
||||
const [activeKey, setActiveKey] = useState(1);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
static defaultProps = {
|
||||
rightOffset: 0,
|
||||
topOffset: 0,
|
||||
leftOffset: 0,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
emojiTabVisible: true,
|
||||
filter: '',
|
||||
activeKey: 1,
|
||||
};
|
||||
|
||||
this.rootPickerNodeRef = createRef();
|
||||
}
|
||||
|
||||
handleEmojiPickerClose = () => {
|
||||
this.props.onEmojiClose();
|
||||
};
|
||||
|
||||
handleFilterChange = (filter: string) => {
|
||||
this.setState({filter});
|
||||
};
|
||||
|
||||
getRootPickerNode = () => {
|
||||
return this.rootPickerNodeRef.current;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {intl} = this.props;
|
||||
let pickerStyle;
|
||||
|
||||
if (this.props.style && !(this.props.style.left === 0 && this.props.style.top === 0)) {
|
||||
if (this.props.placement === 'top' || this.props.placement === 'bottom') {
|
||||
// Only take the top/bottom position passed by React Bootstrap since we want to be right-aligned
|
||||
pickerStyle = {
|
||||
top: this.props.style.top,
|
||||
bottom: this.props.style.bottom,
|
||||
right: this.props?.rightOffset,
|
||||
};
|
||||
} else {
|
||||
pickerStyle = {...this.props.style};
|
||||
}
|
||||
|
||||
if (pickerStyle.top) {
|
||||
pickerStyle.top = (this.props.topOffset || 0) + (pickerStyle.top as number);
|
||||
} else {
|
||||
pickerStyle.top = this.props.topOffset;
|
||||
}
|
||||
|
||||
if (pickerStyle.left) {
|
||||
(pickerStyle.left as number) += (this.props.leftOffset || 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.enableGifPicker && typeof this.props.onGifClick != 'undefined') {
|
||||
return (
|
||||
<div
|
||||
id='emojiGifPicker'
|
||||
ref={this.rootPickerNodeRef}
|
||||
style={pickerStyle}
|
||||
className={classNames('a11y__popup', 'emoji-picker', {
|
||||
bottom: this.props.placement === 'bottom',
|
||||
})}
|
||||
role='dialog'
|
||||
aria-label={this.state.activeKey === 1 ? intl.formatMessage({id: 'emoji_gif_picker.dialog.emojis', defaultMessage: 'Emoji Picker'}) : intl.formatMessage({id: 'emoji_gif_picker.dialog.gifs', defaultMessage: 'GIF Picker'})
|
||||
}
|
||||
|
||||
aria-modal='true'
|
||||
>
|
||||
<Tabs
|
||||
id='emoji-picker-tabs'
|
||||
defaultActiveKey={1}
|
||||
justified={true}
|
||||
mountOnEnter={true}
|
||||
unmountOnExit={true}
|
||||
activeKey={this.state.activeKey}
|
||||
onSelect={(activeKey) => this.setState({activeKey})}
|
||||
>
|
||||
<EmojiPickerHeader handleEmojiPickerClose={this.handleEmojiPickerClose}/>
|
||||
<Tab
|
||||
eventKey={1}
|
||||
title={
|
||||
<div className={'custom-emoji-tab__icon__text'}>
|
||||
<EmojiIcon
|
||||
className='custom-emoji-tab__icon'
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='emoji_gif_picker.tabs.emojis'
|
||||
defaultMessage='Emojis'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
unmountOnExit={true}
|
||||
tabClassName={'custom-emoji-tab'}
|
||||
>
|
||||
<EmojiPicker
|
||||
filter={this.state.filter}
|
||||
onEmojiClick={this.props.onEmojiClick}
|
||||
handleFilterChange={this.handleFilterChange}
|
||||
handleEmojiPickerClose={this.handleEmojiPickerClose}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey={2}
|
||||
title={
|
||||
<div className={'custom-emoji-tab__icon__text'}>
|
||||
<GifIcon
|
||||
className='custom-emoji-tab__icon'
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='emoji_gif_picker.tabs.gifs'
|
||||
defaultMessage='GIFs'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
unmountOnExit={true}
|
||||
tabClassName={'custom-emoji-tab'}
|
||||
>
|
||||
<GifPicker
|
||||
filter={this.state.filter}
|
||||
getRootPickerNode={this.getRootPickerNode}
|
||||
onGifClick={this.props.onGifClick}
|
||||
handleFilterChange={this.handleFilterChange}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const rootPickerNodeRef = useRef<HTMLDivElement>(null);
|
||||
const getRootPickerNode = useCallback(() => rootPickerNodeRef.current, []);
|
||||
|
||||
if (props.enableGifPicker && typeof props.onGifClick != 'undefined') {
|
||||
return (
|
||||
<div
|
||||
id='emojiPicker'
|
||||
style={pickerStyle}
|
||||
className={classNames('a11y__popup', 'emoji-picker', 'emoji-picker--single', {
|
||||
bottom: this.props.placement === 'bottom',
|
||||
})}
|
||||
id='emojiGifPicker'
|
||||
ref={rootPickerNodeRef}
|
||||
className='a11y__popup emoji-picker'
|
||||
role='dialog'
|
||||
aria-label={
|
||||
intl.formatMessage({id: 'emoji_gif_picker.dialog.emojis', defaultMessage: 'Emoji Picker'})}
|
||||
aria-label={activeKey === 1 ? intl.formatMessage({id: 'emoji_gif_picker.dialog.emojis', defaultMessage: 'Emoji Picker'}) : intl.formatMessage({id: 'emoji_gif_picker.dialog.gifs', defaultMessage: 'GIF Picker'})}
|
||||
aria-modal='true'
|
||||
>
|
||||
<EmojiPickerHeader handleEmojiPickerClose={this.handleEmojiPickerClose}/>
|
||||
<EmojiPicker
|
||||
filter={this.state.filter}
|
||||
onEmojiClick={this.props.onEmojiClick}
|
||||
handleFilterChange={this.handleFilterChange}
|
||||
handleEmojiPickerClose={this.handleEmojiPickerClose}
|
||||
onAddCustomEmojiClick={this.props.onAddCustomEmojiClick}
|
||||
/>
|
||||
<Tabs
|
||||
id='emoji-picker-tabs'
|
||||
defaultActiveKey={1}
|
||||
justified={true}
|
||||
mountOnEnter={true}
|
||||
unmountOnExit={true}
|
||||
activeKey={activeKey}
|
||||
onSelect={(activeKey) => setActiveKey(activeKey)}
|
||||
>
|
||||
<EmojiPickerHeader handleEmojiPickerClose={props.onEmojiClose}/>
|
||||
<Tab
|
||||
eventKey={1}
|
||||
title={
|
||||
<div className={'custom-emoji-tab__icon__text'}>
|
||||
<EmojiIcon
|
||||
className='custom-emoji-tab__icon'
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='emoji_gif_picker.tabs.emojis'
|
||||
defaultMessage='Emojis'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
unmountOnExit={true}
|
||||
tabClassName={'custom-emoji-tab'}
|
||||
>
|
||||
<EmojiPicker
|
||||
filter={filter}
|
||||
onEmojiClick={props.onEmojiClick}
|
||||
handleFilterChange={setFilter}
|
||||
handleEmojiPickerClose={props.onEmojiClose}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey={2}
|
||||
title={
|
||||
<div className={'custom-emoji-tab__icon__text'}>
|
||||
<GifIcon
|
||||
className='custom-emoji-tab__icon'
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='emoji_gif_picker.tabs.gifs'
|
||||
defaultMessage='GIFs'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
unmountOnExit={true}
|
||||
tabClassName={'custom-emoji-tab'}
|
||||
>
|
||||
<GifPicker
|
||||
filter={filter}
|
||||
getRootPickerNode={getRootPickerNode}
|
||||
onGifClick={props.onGifClick}
|
||||
handleFilterChange={setFilter}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(EmojiPickerTabs);
|
||||
return (
|
||||
<div
|
||||
id='emojiPicker'
|
||||
className='a11y__popup emoji-picker emoji-picker--single'
|
||||
role='dialog'
|
||||
aria-label={intl.formatMessage({id: 'emoji_gif_picker.dialog.emojis', defaultMessage: 'Emoji Picker'})}
|
||||
aria-modal='true'
|
||||
>
|
||||
<EmojiPickerHeader handleEmojiPickerClose={props.onEmojiClose}/>
|
||||
<EmojiPicker
|
||||
filter={filter}
|
||||
onEmojiClick={props.onEmojiClick}
|
||||
handleFilterChange={setFilter}
|
||||
handleEmojiPickerClose={props.onEmojiClose}
|
||||
onAddCustomEmojiClick={props.onAddCustomEmojiClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
145
webapp/channels/src/components/emoji_picker/use_emoji_picker.tsx
Normal file
145
webapp/channels/src/components/emoji_picker/use_emoji_picker.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {UseFloatingOptions, UseFloatingReturn} from '@floating-ui/react';
|
||||
import {
|
||||
flip,
|
||||
FloatingFocusManager,
|
||||
FloatingOverlay,
|
||||
FloatingPortal,
|
||||
offset,
|
||||
shift,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
useRole,
|
||||
} from '@floating-ui/react';
|
||||
import React, {useCallback} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
|
||||
import {getIsMobileView} from 'selectors/views/browser';
|
||||
|
||||
import {RootHtmlPortalId} from 'utils/constants';
|
||||
|
||||
import EmojiPickerTabs from './emoji_picker_tabs';
|
||||
|
||||
export const useEmojiPickerOffset = 4;
|
||||
|
||||
type UseEmojiPickerOptions = {
|
||||
showEmojiPicker: boolean;
|
||||
setShowEmojiPicker: (showEmojiPicker: boolean) => void;
|
||||
|
||||
enableGifPicker?: boolean;
|
||||
onAddCustomEmojiClick?: () => void;
|
||||
onEmojiClick: (emoji: Emoji) => void;
|
||||
onGifClick?: (gif: string) => void;
|
||||
|
||||
/**
|
||||
* Replaces the middleware for positioning the emoji picker in cases where we want it positioned differently.
|
||||
*/
|
||||
overrideMiddleware?: UseFloatingOptions['middleware'];
|
||||
}
|
||||
|
||||
type UseEmojiPickerReturn = {
|
||||
emojiPicker: React.ReactNode;
|
||||
getReferenceProps: ReturnType<typeof useInteractions>['getReferenceProps'];
|
||||
setReference: UseFloatingReturn['refs']['setReference'];
|
||||
}
|
||||
|
||||
export default function useEmojiPicker({
|
||||
showEmojiPicker,
|
||||
setShowEmojiPicker,
|
||||
|
||||
enableGifPicker,
|
||||
onAddCustomEmojiClick,
|
||||
onEmojiClick,
|
||||
onGifClick,
|
||||
|
||||
overrideMiddleware,
|
||||
}: UseEmojiPickerOptions): UseEmojiPickerReturn {
|
||||
const isMobileView = useSelector(getIsMobileView);
|
||||
|
||||
const hideEmojiPicker = useCallback(() => setShowEmojiPicker(false), [setShowEmojiPicker]);
|
||||
|
||||
let middleware: UseFloatingOptions['middleware'];
|
||||
if (isMobileView) {
|
||||
// Disable middleware in mobile view because we use CSS to make the emoji picker fullscreen
|
||||
middleware = [];
|
||||
} else if (overrideMiddleware) {
|
||||
middleware = overrideMiddleware;
|
||||
} else {
|
||||
middleware = [
|
||||
offset(useEmojiPickerOffset),
|
||||
shift(),
|
||||
flip({
|
||||
fallbackAxisSideDirection: 'end',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// Set up Floating UI
|
||||
const {context: floatingContext, floatingStyles, refs} = useFloating({
|
||||
open: showEmojiPicker,
|
||||
onOpenChange: setShowEmojiPicker,
|
||||
|
||||
middleware,
|
||||
placement: 'top',
|
||||
});
|
||||
|
||||
const clickInteractions = useClick(floatingContext);
|
||||
const dismissInteraction = useDismiss(floatingContext);
|
||||
const role = useRole(floatingContext);
|
||||
|
||||
const {getReferenceProps, getFloatingProps} = useInteractions([
|
||||
clickInteractions,
|
||||
dismissInteraction,
|
||||
role,
|
||||
]);
|
||||
|
||||
let emojiPicker = (
|
||||
<EmojiPickerTabs
|
||||
enableGifPicker={enableGifPicker}
|
||||
onAddCustomEmojiClick={onAddCustomEmojiClick}
|
||||
onEmojiClose={hideEmojiPicker}
|
||||
onEmojiClick={onEmojiClick}
|
||||
onGifClick={onGifClick}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isMobileView) {
|
||||
// On mobile, we use Floating UI to manage the portal and opening/closing the picker, but we don't use its
|
||||
// position because the picker is fullscreen
|
||||
emojiPicker = (
|
||||
<div ref={refs.setFloating}>
|
||||
{emojiPicker}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
emojiPicker = (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={{...floatingStyles}}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{emojiPicker}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
emojiPicker: (
|
||||
showEmojiPicker && <FloatingPortal id={RootHtmlPortalId}>
|
||||
<FloatingOverlay className='emoji-picker-overlay'>
|
||||
<FloatingFocusManager context={floatingContext}>
|
||||
{emojiPicker}
|
||||
</FloatingFocusManager>
|
||||
</FloatingOverlay>
|
||||
</FloatingPortal>
|
||||
),
|
||||
getReferenceProps,
|
||||
setReference: refs.setReference,
|
||||
};
|
||||
}
|
@ -59,27 +59,35 @@ type Props = {
|
||||
};
|
||||
|
||||
const PostOptions = (props: Props): JSX.Element => {
|
||||
const dotMenuRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [showDotMenu, setShowDotMenu] = useState(false);
|
||||
const [showActionsMenu, setShowActionsMenu] = useState(false);
|
||||
|
||||
const toggleEmojiPicker = useCallback(() => {
|
||||
setShowEmojiPicker(!showEmojiPicker);
|
||||
props.handleDropdownOpened!(!showEmojiPicker);
|
||||
}, [props.handleDropdownOpened, showEmojiPicker]);
|
||||
const toggleEmojiPicker = useCallback((show: boolean) => {
|
||||
setShowEmojiPicker(show);
|
||||
props.handleDropdownOpened!(show);
|
||||
}, [props.handleDropdownOpened]);
|
||||
|
||||
const lastEmittedFrom = useRef(props.shortcutReactToLastPostEmittedFrom);
|
||||
useEffect(() => {
|
||||
// Confirm that lastEmittedFrom actually changed to avoid toggling the emoji picker when another dependency
|
||||
// changes without the user pressing the hotkey again
|
||||
if (lastEmittedFrom.current === props.shortcutReactToLastPostEmittedFrom) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastEmittedFrom.current = props.shortcutReactToLastPostEmittedFrom;
|
||||
|
||||
const locationToUse = props.location === 'RHS_COMMENT' ? Locations.RHS_ROOT : props.location;
|
||||
|
||||
if (props.isLastPost &&
|
||||
(props.shortcutReactToLastPostEmittedFrom === locationToUse) &&
|
||||
props.isPostHeaderVisible) {
|
||||
toggleEmojiPicker();
|
||||
props.actions.emitShortcutReactToLastPostFrom(Locations.NO_WHERE);
|
||||
toggleEmojiPicker(!showEmojiPicker);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.isLastPost, props.shortcutReactToLastPostEmittedFrom, props.location, props.isPostHeaderVisible]);
|
||||
}, [props.isLastPost, props.shortcutReactToLastPostEmittedFrom, props.location, props.isPostHeaderVisible, showEmojiPicker]);
|
||||
|
||||
const {
|
||||
channelIsArchived,
|
||||
@ -108,8 +116,6 @@ const PostOptions = (props: Props): JSX.Element => {
|
||||
props.handleDropdownOpened!(open);
|
||||
};
|
||||
|
||||
const getDotMenuRef = () => dotMenuRef.current;
|
||||
|
||||
const isPostDeleted = post && post.state === Posts.POST_DELETED;
|
||||
const hoverLocal = props.hover || showEmojiPicker || showDotMenu || showActionsMenu;
|
||||
const showCommentIcon = isFromAutoResponder || (!systemMessage && (isMobileView ||
|
||||
@ -160,9 +166,8 @@ const PostOptions = (props: Props): JSX.Element => {
|
||||
location={props.location}
|
||||
postId={post.id}
|
||||
teamId={props.teamId}
|
||||
getDotMenuRef={getDotMenuRef}
|
||||
showEmojiPicker={showEmojiPicker}
|
||||
toggleEmojiPicker={toggleEmojiPicker}
|
||||
setShowEmojiPicker={toggleEmojiPicker}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
@ -278,7 +283,6 @@ const PostOptions = (props: Props): JSX.Element => {
|
||||
} else if (!props.isPostBeingEdited) {
|
||||
options = (
|
||||
<ul
|
||||
ref={dotMenuRef}
|
||||
data-testid={`post-menu-${props.post.id}`}
|
||||
className={classnames('col post-menu', {'post-menu--position': !hoverLocal && showCommentIcon})}
|
||||
>
|
||||
|
@ -1,41 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/post_view/PostReaction should match snapshot 1`] = `
|
||||
<Memo(ChannelPermissionGate)
|
||||
channelId="current_channel_id"
|
||||
permissions={
|
||||
Array [
|
||||
"add_reaction",
|
||||
]
|
||||
}
|
||||
teamId="current_team_id"
|
||||
>
|
||||
<Connect(EmojiPickerOverlay)
|
||||
onEmojiClick={[Function]}
|
||||
onHide={[MockFunction]}
|
||||
show={false}
|
||||
target={[MockFunction]}
|
||||
topOffset={-7}
|
||||
/>
|
||||
<WithTooltip
|
||||
title={
|
||||
Object {
|
||||
"defaultMessage": "Add Reaction",
|
||||
"id": "post_info.tooltip.add_reactions",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
aria-label="Add Reaction"
|
||||
className="post-menu__item post-menu__item--reactions"
|
||||
data-testid="post-reaction-emoji-icon"
|
||||
id="CENTER_reaction_post_id_1"
|
||||
onClick={[MockFunction]}
|
||||
>
|
||||
<Memo(EmojiIcon)
|
||||
className="icon icon--small"
|
||||
/>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
</Memo(ChannelPermissionGate)>
|
||||
`;
|
@ -3,10 +3,12 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import {Permissions} from 'mattermost-redux/constants';
|
||||
|
||||
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import PostReaction, {type PostReaction as PostReactionComponent} from './post_reaction';
|
||||
import PostReaction from './post_reaction';
|
||||
|
||||
describe('components/post_view/PostReaction', () => {
|
||||
const baseProps = {
|
||||
@ -16,24 +18,62 @@ describe('components/post_view/PostReaction', () => {
|
||||
getDotMenuRef: jest.fn(),
|
||||
showIcon: false,
|
||||
showEmojiPicker: false,
|
||||
toggleEmojiPicker: jest.fn(),
|
||||
setShowEmojiPicker: jest.fn(),
|
||||
actions: {
|
||||
toggleReaction: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallowWithIntl(<PostReaction {...baseProps}/>);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
const userId = 'userId';
|
||||
const initialState = {
|
||||
entities: {
|
||||
roles: {
|
||||
roles: {
|
||||
system_user: TestHelper.getRoleMock({permissions: [Permissions.ADD_REACTION]}),
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: userId,
|
||||
profiles: {
|
||||
userId: TestHelper.getUserMock({id: userId, roles: 'system_user'}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('should not render the emoji picker initially', async () => {
|
||||
const {rerender} = renderWithContext(
|
||||
<PostReaction {...baseProps}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search emojis')).not.toBeInTheDocument();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
rerender(
|
||||
<PostReaction
|
||||
{...baseProps}
|
||||
showEmojiPicker={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search emojis')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call toggleReaction and toggleEmojiPicker on handleToggleEmoji', () => {
|
||||
const wrapper = shallowWithIntl(<PostReaction {...baseProps}/>);
|
||||
const instance = wrapper.instance() as PostReactionComponent;
|
||||
test('should toggle the reaction and close the emoji picker when an emoji is selected', async () => {
|
||||
renderWithContext(
|
||||
<PostReaction
|
||||
{...baseProps}
|
||||
showEmojiPicker={true}
|
||||
/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
userEvent.type(screen.getByPlaceholderText('Search emojis'), '{enter}');
|
||||
|
||||
instance.handleToggleEmoji(TestHelper.getCustomEmojiMock({name: 'smile'}));
|
||||
expect(baseProps.actions.toggleReaction).toHaveBeenCalledTimes(1);
|
||||
expect(baseProps.actions.toggleReaction).toHaveBeenCalledWith('post_id_1', 'smile');
|
||||
expect(baseProps.toggleEmojiPicker).toHaveBeenCalledTimes(1);
|
||||
expect(baseProps.actions.toggleReaction).toHaveBeenCalledWith('post_id_1', 'grinning');
|
||||
expect(baseProps.setShowEmojiPicker).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
@ -2,114 +2,87 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import type {WrappedComponentProps} from 'react-intl';
|
||||
import {defineMessages, injectIntl} from 'react-intl';
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
|
||||
import Permissions from 'mattermost-redux/constants/permissions';
|
||||
import {getEmojiName} from 'mattermost-redux/utils/emoji_utils';
|
||||
|
||||
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
|
||||
import useEmojiPicker from 'components/emoji_picker/use_emoji_picker';
|
||||
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
|
||||
import EmojiIcon from 'components/widgets/icons/emoji_icon';
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
import {Locations} from 'utils/constants';
|
||||
|
||||
const TOP_OFFSET = -7;
|
||||
|
||||
const messages = defineMessages({
|
||||
addReaction: {
|
||||
id: 'post_info.tooltip.add_reactions',
|
||||
defaultMessage: 'Add Reaction',
|
||||
},
|
||||
});
|
||||
|
||||
export type Props = WrappedComponentProps & {
|
||||
export type Props = {
|
||||
channelId?: string;
|
||||
postId: string;
|
||||
teamId: string;
|
||||
getDotMenuRef: () => HTMLUListElement | null;
|
||||
location?: keyof typeof Locations;
|
||||
setShowEmojiPicker: (showEmojiPicker: boolean) => void;
|
||||
showEmojiPicker: boolean;
|
||||
toggleEmojiPicker: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
actions: {
|
||||
toggleReaction: (postId: string, emojiName: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
type State = {
|
||||
location: keyof typeof Locations;
|
||||
showEmojiPicker: boolean;
|
||||
}
|
||||
export default function PostReaction({
|
||||
channelId,
|
||||
location = Locations.CENTER,
|
||||
postId,
|
||||
teamId,
|
||||
showEmojiPicker,
|
||||
setShowEmojiPicker,
|
||||
actions: {
|
||||
toggleReaction,
|
||||
},
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
|
||||
export class PostReaction extends React.PureComponent<Props, State> {
|
||||
public static defaultProps: Partial<Props> = {
|
||||
location: Locations.CENTER as 'CENTER',
|
||||
showEmojiPicker: false,
|
||||
};
|
||||
|
||||
handleToggleEmoji = (emoji: Emoji): void => {
|
||||
this.setState({showEmojiPicker: false});
|
||||
const handleEmojiClick = useCallback((emoji: Emoji) => {
|
||||
const emojiName = getEmojiName(emoji);
|
||||
this.props.actions.toggleReaction(this.props.postId, emojiName);
|
||||
this.props.toggleEmojiPicker();
|
||||
};
|
||||
toggleReaction(postId, emojiName);
|
||||
|
||||
render() {
|
||||
const {
|
||||
channelId,
|
||||
location,
|
||||
postId,
|
||||
showEmojiPicker,
|
||||
teamId,
|
||||
intl,
|
||||
} = this.props;
|
||||
setShowEmojiPicker(false);
|
||||
}, [postId, setShowEmojiPicker, toggleReaction]);
|
||||
|
||||
let spaceRequiredAbove;
|
||||
let spaceRequiredBelow;
|
||||
if (location === Locations.RHS_ROOT || location === Locations.RHS_COMMENT) {
|
||||
spaceRequiredAbove = EmojiPickerOverlay.RHS_SPACE_REQUIRED_ABOVE;
|
||||
spaceRequiredBelow = EmojiPickerOverlay.RHS_SPACE_REQUIRED_BELOW;
|
||||
}
|
||||
const {
|
||||
emojiPicker,
|
||||
getReferenceProps,
|
||||
setReference,
|
||||
} = useEmojiPicker({
|
||||
showEmojiPicker,
|
||||
setShowEmojiPicker,
|
||||
|
||||
return (
|
||||
<ChannelPermissionGate
|
||||
channelId={channelId}
|
||||
teamId={teamId}
|
||||
permissions={[Permissions.ADD_REACTION]}
|
||||
>
|
||||
<>
|
||||
<EmojiPickerOverlay
|
||||
show={showEmojiPicker}
|
||||
target={this.props.getDotMenuRef}
|
||||
onHide={this.props.toggleEmojiPicker}
|
||||
onEmojiClick={this.handleToggleEmoji}
|
||||
topOffset={TOP_OFFSET}
|
||||
spaceRequiredAbove={spaceRequiredAbove}
|
||||
spaceRequiredBelow={spaceRequiredBelow}
|
||||
/>
|
||||
<WithTooltip
|
||||
title={messages.addReaction}
|
||||
>
|
||||
<button
|
||||
data-testid='post-reaction-emoji-icon'
|
||||
id={`${location}_reaction_${postId}`}
|
||||
aria-label={intl.formatMessage({id: 'post_info.tooltip.add_reactions', defaultMessage: 'Add Reaction'})}
|
||||
className={classNames('post-menu__item', 'post-menu__item--reactions', {
|
||||
'post-menu__item--active': showEmojiPicker,
|
||||
})}
|
||||
onClick={this.props.toggleEmojiPicker}
|
||||
>
|
||||
<EmojiIcon className='icon icon--small'/>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
</>
|
||||
</ChannelPermissionGate>
|
||||
);
|
||||
}
|
||||
onEmojiClick: handleEmojiClick,
|
||||
});
|
||||
|
||||
const ariaLabel = intl.formatMessage({id: 'post_info.tooltip.add_reactions', defaultMessage: 'Add Reaction'});
|
||||
|
||||
return (
|
||||
<ChannelPermissionGate
|
||||
channelId={channelId}
|
||||
teamId={teamId}
|
||||
permissions={[Permissions.ADD_REACTION]}
|
||||
>
|
||||
<WithTooltip title={ariaLabel}>
|
||||
<button
|
||||
ref={setReference}
|
||||
data-testid='post-reaction-emoji-icon'
|
||||
id={`${location}_reaction_${postId}`}
|
||||
aria-label={ariaLabel}
|
||||
className={classNames('post-menu__item', 'post-menu__item--reactions', {
|
||||
'post-menu__item--active': showEmojiPicker,
|
||||
})}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
<EmojiIcon className='icon icon--small'/>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
{emojiPicker}
|
||||
</ChannelPermissionGate>
|
||||
);
|
||||
}
|
||||
|
||||
export default injectIntl(PostReaction);
|
||||
|
@ -56,11 +56,19 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__add {
|
||||
&.Reaction__add {
|
||||
position: relative;
|
||||
font-size: 20px;
|
||||
line-height: 0;
|
||||
vertical-align: middle;
|
||||
|
||||
&.Reaction__add--open {
|
||||
background-color: rgba(var(--button-bg-rgb), 0.08);
|
||||
color: functions.v(button-bg);
|
||||
fill: functions.v(button-bg);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&__emoji {
|
||||
|
@ -48,52 +48,36 @@ exports[`components/ReactionList should render when there are reactions 1`] = `
|
||||
]
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="post-add-reaction"
|
||||
>
|
||||
<span
|
||||
className="emoji-picker__container"
|
||||
>
|
||||
<Connect(EmojiPickerOverlay)
|
||||
onEmojiClick={[Function]}
|
||||
onHide={[Function]}
|
||||
rightOffset={15}
|
||||
show={false}
|
||||
target={[Function]}
|
||||
topOffset={-5}
|
||||
/>
|
||||
<Memo(ChannelPermissionGate)
|
||||
channelId=""
|
||||
permissions={
|
||||
Array [
|
||||
"add_reaction",
|
||||
]
|
||||
}
|
||||
teamId="teamId"
|
||||
>
|
||||
<WithTooltip
|
||||
title={
|
||||
Object {
|
||||
"defaultMessage": "Add a reaction",
|
||||
"id": "reaction_list.addReactionTooltip",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
aria-label="Add a reaction"
|
||||
className="Reaction"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<span
|
||||
className="Reaction__add"
|
||||
id="addReaction-post_id"
|
||||
>
|
||||
<AddReactionIcon />
|
||||
</span>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
</Memo(ChannelPermissionGate)>
|
||||
</span>
|
||||
</div>
|
||||
<AddReactionButton
|
||||
onEmojiClick={[Function]}
|
||||
post={
|
||||
Object {
|
||||
"channel_id": "",
|
||||
"create_at": 0,
|
||||
"delete_at": 0,
|
||||
"edit_at": 0,
|
||||
"hashtags": "",
|
||||
"id": "post_id",
|
||||
"is_pinned": false,
|
||||
"message": "post message",
|
||||
"metadata": Object {
|
||||
"embeds": Array [],
|
||||
"emojis": Array [],
|
||||
"files": Array [],
|
||||
"images": Object {},
|
||||
"reactions": Array [],
|
||||
},
|
||||
"original_id": "",
|
||||
"pending_post_id": "",
|
||||
"props": Object {},
|
||||
"reply_count": 0,
|
||||
"root_id": "",
|
||||
"type": "system_add_remove",
|
||||
"update_at": 0,
|
||||
"user_id": "user_id",
|
||||
}
|
||||
}
|
||||
teamId="teamId"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {Permissions} from 'mattermost-redux/constants';
|
||||
|
||||
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import AddReactionButton from './add_reaction_button';
|
||||
|
||||
describe('AddReactionButton', () => {
|
||||
const userId = 'userId';
|
||||
|
||||
const initialState = {
|
||||
entities: {
|
||||
roles: {
|
||||
roles: {
|
||||
system_user: TestHelper.getRoleMock({permissions: [Permissions.ADD_REACTION]}),
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: userId,
|
||||
profiles: {
|
||||
userId: TestHelper.getUserMock({id: userId, roles: 'system_user'}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('should show emoji picker when clicked and then close it when an emoji is selected', async () => {
|
||||
const props = {
|
||||
post: TestHelper.getPostMock({user_id: userId, channel_id: 'channelId'}),
|
||||
teamId: 'teamId',
|
||||
onEmojiClick: jest.fn(),
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<AddReactionButton {...props}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Emoji Picker')).not.toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByLabelText('Add a reaction'));
|
||||
|
||||
expect(screen.queryByText('Emoji Picker')).toBeVisible();
|
||||
|
||||
// Search for an emoji instead of clicking on one because the emoji picker doesn't render items when testing
|
||||
userEvent.type(screen.getByPlaceholderText('Search emojis'), 'banana{enter}');
|
||||
|
||||
expect(props.onEmojiClick).toHaveBeenCalledWith(expect.objectContaining({short_name: 'banana'}));
|
||||
expect(screen.queryByText('Emoji Picker')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
|
||||
import {Permissions} from 'mattermost-redux/constants';
|
||||
|
||||
import useEmojiPicker from 'components/emoji_picker/use_emoji_picker';
|
||||
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
|
||||
import AddReactionIcon from 'components/widgets/icons/add_reaction_icon';
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
type Props = {
|
||||
post: Post;
|
||||
teamId: string;
|
||||
|
||||
onEmojiClick: (emoji: Emoji) => void;
|
||||
}
|
||||
|
||||
export default function AddReactionButton({
|
||||
post,
|
||||
teamId,
|
||||
|
||||
onEmojiClick,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
|
||||
const handleEmojiClick = useCallback((emoji: Emoji) => {
|
||||
onEmojiClick(emoji);
|
||||
setShowEmojiPicker(false);
|
||||
}, [onEmojiClick]);
|
||||
|
||||
const {
|
||||
emojiPicker,
|
||||
getReferenceProps,
|
||||
setReference,
|
||||
} = useEmojiPicker({
|
||||
showEmojiPicker,
|
||||
setShowEmojiPicker,
|
||||
|
||||
onEmojiClick: handleEmojiClick,
|
||||
});
|
||||
|
||||
const ariaLabel = intl.formatMessage({id: 'reaction.add.ariaLabel', defaultMessage: 'Add a reaction'});
|
||||
|
||||
return (
|
||||
<span className='emoji-picker__container'>
|
||||
<ChannelPermissionGate
|
||||
channelId={post.channel_id}
|
||||
teamId={teamId}
|
||||
permissions={[Permissions.ADD_REACTION]}
|
||||
>
|
||||
<WithTooltip title={ariaLabel}>
|
||||
<button
|
||||
id={`addReaction-${post.id}`}
|
||||
ref={setReference}
|
||||
aria-label={ariaLabel}
|
||||
className={classNames('Reaction Reaction__add', {
|
||||
'Reaction__add--open': showEmojiPicker,
|
||||
})}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
<AddReactionIcon/>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
</ChannelPermissionGate>
|
||||
{emojiPicker}
|
||||
</span>
|
||||
);
|
||||
}
|
@ -2,32 +2,18 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {defineMessages} from 'react-intl';
|
||||
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {Reaction as ReactionType} from '@mattermost/types/reactions';
|
||||
|
||||
import Permissions from 'mattermost-redux/constants/permissions';
|
||||
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 Reaction from 'components/post_view/reaction';
|
||||
import AddReactionIcon from 'components/widgets/icons/add_reaction_icon';
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
import {localizeMessage} from 'utils/utils';
|
||||
|
||||
const DEFAULT_EMOJI_PICKER_RIGHT_OFFSET = 15;
|
||||
const EMOJI_PICKER_WIDTH_OFFSET = 260;
|
||||
|
||||
const messages = defineMessages({
|
||||
addAReaction: {
|
||||
id: 'reaction_list.addReactionTooltip',
|
||||
defaultMessage: 'Add a reaction',
|
||||
},
|
||||
});
|
||||
import AddReactionButton from './add_reaction_button';
|
||||
|
||||
type Props = {
|
||||
|
||||
@ -62,18 +48,14 @@ type Props = {
|
||||
|
||||
type State = {
|
||||
emojiNames: string[];
|
||||
showEmojiPicker: boolean;
|
||||
};
|
||||
|
||||
export default class ReactionList extends React.PureComponent<Props, State> {
|
||||
private addReactionButtonRef = React.createRef<HTMLButtonElement>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
emojiNames: [],
|
||||
showEmojiPicker: false,
|
||||
};
|
||||
}
|
||||
|
||||
@ -89,25 +71,11 @@ export default class ReactionList extends React.PureComponent<Props, State> {
|
||||
return (emojiNames === state.emojiNames) ? null : {emojiNames};
|
||||
}
|
||||
|
||||
getTarget = (): HTMLButtonElement | null => {
|
||||
return this.addReactionButtonRef.current;
|
||||
};
|
||||
|
||||
handleEmojiClick = (emoji: Emoji): void => {
|
||||
this.setState({showEmojiPicker: false});
|
||||
const emojiName = getEmojiName(emoji);
|
||||
this.props.actions.toggleReaction(this.props.post.id, emojiName);
|
||||
};
|
||||
|
||||
hideEmojiPicker = (): void => {
|
||||
this.setState({showEmojiPicker: false});
|
||||
};
|
||||
|
||||
toggleEmojiPicker = (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
|
||||
e?.stopPropagation();
|
||||
this.setState({showEmojiPicker: !this.state.showEmojiPicker});
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
const reactionsByName = new Map();
|
||||
|
||||
@ -141,69 +109,24 @@ export default class ReactionList extends React.PureComponent<Props, State> {
|
||||
return null;
|
||||
});
|
||||
|
||||
const addReactionButton = this.getTarget();
|
||||
let rightOffset = DEFAULT_EMOJI_PICKER_RIGHT_OFFSET;
|
||||
if (addReactionButton) {
|
||||
rightOffset = window.innerWidth - addReactionButton.getBoundingClientRect().right - EMOJI_PICKER_WIDTH_OFFSET;
|
||||
|
||||
if (rightOffset < 0) {
|
||||
rightOffset = DEFAULT_EMOJI_PICKER_RIGHT_OFFSET;
|
||||
}
|
||||
}
|
||||
|
||||
let emojiPicker = null;
|
||||
let addReaction = null;
|
||||
if (this.props.canAddReactions) {
|
||||
emojiPicker = (
|
||||
<span className='emoji-picker__container'>
|
||||
<EmojiPickerOverlay
|
||||
show={this.state.showEmojiPicker}
|
||||
target={this.getTarget}
|
||||
onHide={this.hideEmojiPicker}
|
||||
onEmojiClick={this.handleEmojiClick}
|
||||
rightOffset={rightOffset}
|
||||
topOffset={-5}
|
||||
/>
|
||||
<ChannelPermissionGate
|
||||
channelId={this.props.post.channel_id}
|
||||
teamId={this.props.teamId}
|
||||
permissions={[Permissions.ADD_REACTION]}
|
||||
>
|
||||
<WithTooltip
|
||||
title={messages.addAReaction}
|
||||
>
|
||||
<button
|
||||
aria-label={localizeMessage({id: 'reaction.add.ariaLabel', defaultMessage: 'Add a reaction'})}
|
||||
className='Reaction'
|
||||
onClick={this.toggleEmojiPicker}
|
||||
>
|
||||
<span
|
||||
id={`addReaction-${this.props.post.id}`}
|
||||
className='Reaction__add'
|
||||
ref={this.addReactionButtonRef}
|
||||
>
|
||||
<AddReactionIcon/>
|
||||
</span>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
</ChannelPermissionGate>
|
||||
</span>
|
||||
addReaction = (
|
||||
<AddReactionButton
|
||||
post={this.props.post}
|
||||
teamId={this.props.teamId}
|
||||
onEmojiClick={this.handleEmojiClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let addReactionClassName = 'post-add-reaction';
|
||||
if (this.state.showEmojiPicker) {
|
||||
addReactionClassName += ' post-add-reaction-emoji-picker-open';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={localizeMessage({id: 'reaction.container.ariaLabel', defaultMessage: 'reactions'})}
|
||||
className='post-reaction-list'
|
||||
>
|
||||
{reactions}
|
||||
<div className={addReactionClassName}>
|
||||
{emojiPicker}
|
||||
</div>
|
||||
{addReaction}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -4835,7 +4835,6 @@
|
||||
"reaction_limit_reached_modal.body": "Oops! It looks like we've hit a ceiling on emoji reactions for this message. Please contact your system administrator for any adjustments to this limit.",
|
||||
"reaction_limit_reached_modal.body.admin": "Oops! It looks like we've hit a ceiling on emoji reactions for this message. We've <link>set a limit</link> to keep things running smoothly on your server. As a system administrator, you can adjust this limit from the <linkAdmin>system console</linkAdmin>.",
|
||||
"reaction_limit_reached_modal.title": "You've reached the reaction limit",
|
||||
"reaction_list.addReactionTooltip": "Add a reaction",
|
||||
"reaction.add.ariaLabel": "Add a reaction",
|
||||
"reaction.clickToAdd": "(click to add)",
|
||||
"reaction.clickToRemove": "(click to remove)",
|
||||
|
@ -1,3 +1,5 @@
|
||||
@use "utils/variables";
|
||||
|
||||
.emoji-picker {
|
||||
pointer-events: auto;
|
||||
|
||||
@ -49,6 +51,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker-overlay {
|
||||
z-index: variables.$z-index-popover;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.custom-emoji-tab__icon__text {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -86,11 +86,11 @@
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
position: absolute;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
// position: relative;
|
||||
// z-index: 1100;
|
||||
// display: flex;
|
||||
width: 350px;
|
||||
flex-direction: column;
|
||||
// flex-direction: column;
|
||||
border: 1px solid;
|
||||
border-radius: var(--radius-s);
|
||||
margin-right: 3px;
|
||||
@ -118,10 +118,6 @@
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
flex: 0 0 34px;
|
||||
|
@ -202,12 +202,6 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
position: absolute;
|
||||
top: -361px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 0;
|
||||
}
|
||||
|
@ -343,12 +343,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
position: absolute;
|
||||
top: -361px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
.custom-textarea {
|
||||
overflow: auto;
|
||||
@ -658,11 +652,9 @@
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.post-add-reaction {
|
||||
.Reaction {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.Reaction__add {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1373,13 +1365,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.post-add-reaction {
|
||||
.Reaction__add {
|
||||
display: inline-block;
|
||||
|
||||
.Reaction {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.post__body {
|
||||
@ -1488,21 +1477,8 @@
|
||||
align-items: center;
|
||||
padding: 4px 0 0;
|
||||
|
||||
.post-add-reaction-emoji-picker-open {
|
||||
.Reaction__add {
|
||||
display: inline-block;
|
||||
|
||||
.Reaction {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-add-reaction-emoji-picker-open {
|
||||
.Reaction {
|
||||
background-color: rgba(var(--button-bg-rgb), 0.08);
|
||||
color: functions.v(button-bg);
|
||||
fill: functions.v(button-bg);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -467,7 +467,6 @@
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
z-index: 1070;
|
||||
// !important is used to overide inline styles
|
||||
// used on larger screens
|
||||
top: 0 !important;
|
||||
@ -482,10 +481,6 @@
|
||||
margin-top: 66px;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.search-grid-container {
|
||||
height: calc(100vh - 170px);
|
||||
}
|
||||
@ -861,11 +856,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.post-add-reaction {
|
||||
.Reaction {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.Reaction__add {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.browser--ie & {
|
||||
@ -2082,19 +2075,6 @@
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// overides edit modal body element's css position
|
||||
// to allow emoji picker to fill the screen on mobile screens < 480
|
||||
.edit-modal-body--add-reaction {
|
||||
position: static;
|
||||
|
||||
.emoji-picker {
|
||||
top: -1px !important;
|
||||
left: -1px !important;
|
||||
width: calc(100% + 2px);
|
||||
height: calc(100% + 2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 640px) {
|
||||
|
@ -1657,10 +1657,6 @@ export const Constants = {
|
||||
OPEN_TEAM: 'O',
|
||||
THREADS: 'threads',
|
||||
MAX_POST_LEN: 4000,
|
||||
EMOJI_SIZE: 16,
|
||||
DEFAULT_EMOJI_PICKER_LEFT_OFFSET: 87,
|
||||
DEFAULT_EMOJI_PICKER_RIGHT_OFFSET: 15,
|
||||
EMOJI_PICKER_WIDTH_OFFSET: 295,
|
||||
SIDEBAR_MINIMUM_WIDTH: 640,
|
||||
THEME_ELEMENTS: [
|
||||
{
|
||||
|
57
webapp/channels/src/utils/floating.ts
Normal file
57
webapp/channels/src/utils/floating.ts
Normal file
@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {detectOverflow} from '@floating-ui/react';
|
||||
import type {Boundary, MiddlewareState} from '@floating-ui/react-dom';
|
||||
|
||||
export type HorizontallyWithinOptions = {
|
||||
|
||||
/**
|
||||
* An element or Rect that the floating element should be aligned with. Often, this will be the result of calling
|
||||
* document.getElementById with the ID of a parent element (like the post textbox for the emoji picker).
|
||||
*
|
||||
* See Floating UI's documentation on detectOverflow for more details.
|
||||
*/
|
||||
boundary?: Boundary | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* horizontallyWithin is a middleware for useFloating which shifts the floating element left or right to try to keep
|
||||
* it within the horizontal boundaries of the given boundary element.
|
||||
*
|
||||
* If the floating element is wider than the boundary, it'll be positioned right aligned with the boundary.
|
||||
*/
|
||||
export function horizontallyWithin(options: HorizontallyWithinOptions = {}) {
|
||||
return ({
|
||||
name: 'horizontallyWithin',
|
||||
options,
|
||||
async fn(state: MiddlewareState) {
|
||||
const {boundary} = options;
|
||||
|
||||
if (!boundary) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const overflow = await detectOverflow(state, {
|
||||
boundary,
|
||||
});
|
||||
|
||||
if (overflow.right > 0) {
|
||||
// The floating element is overflowing on the right, so shift left
|
||||
return {
|
||||
x: state.x - overflow.right,
|
||||
y: state.y,
|
||||
};
|
||||
} else if (overflow.left > 0) {
|
||||
// The floating element is overflowing on the left, so shift right
|
||||
return {
|
||||
x: state.x + overflow.left,
|
||||
y: state.y,
|
||||
};
|
||||
}
|
||||
|
||||
// The floating element is horizontally within the boundary, so do nothing
|
||||
return {};
|
||||
},
|
||||
});
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {popOverOverlayPosition} from 'utils/position_utils';
|
||||
|
||||
test('Should return placement position for overlay based on bounds, space required and innerHeight', () => {
|
||||
const targetBounds = {
|
||||
top: 400,
|
||||
bottom: 500,
|
||||
};
|
||||
|
||||
expect(popOverOverlayPosition(targetBounds as DOMRect, 1000, 300)).toEqual('top');
|
||||
expect(popOverOverlayPosition(targetBounds as DOMRect, 1000, 500, 300)).toEqual('bottom');
|
||||
expect(popOverOverlayPosition(targetBounds as DOMRect, 1000, 450)).toEqual('bottom');
|
||||
expect(popOverOverlayPosition(targetBounds as DOMRect, 1000, 600)).toEqual('left');
|
||||
});
|
@ -1,24 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ComponentProps} from 'react';
|
||||
import type {Overlay} from 'react-bootstrap';
|
||||
|
||||
export function popOverOverlayPosition(
|
||||
targetBounds: DOMRect,
|
||||
innerHeight: number,
|
||||
spaceRequiredAbove: number,
|
||||
spaceRequiredBelow?: number,
|
||||
horizontalPosition?: 'left' | 'right',
|
||||
) {
|
||||
let placement: ComponentProps<typeof Overlay>['placement'];
|
||||
|
||||
if (targetBounds.top > spaceRequiredAbove) {
|
||||
placement = 'top';
|
||||
} else if (innerHeight - targetBounds.bottom > (spaceRequiredBelow || spaceRequiredAbove)) {
|
||||
placement = 'bottom';
|
||||
} else {
|
||||
placement = horizontalPosition || 'left';
|
||||
}
|
||||
return placement;
|
||||
}
|
Loading…
Reference in New Issue
Block a user