diff --git a/packages/react/src/hooks/useIsMobileViewport.js b/packages/react/src/hooks/useIsMobileViewport.js new file mode 100644 index 0000000000..532fc202bd --- /dev/null +++ b/packages/react/src/hooks/useIsMobileViewport.js @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; + +export const MOBILE_BREAKPOINT = 499; + +const getIsMobileViewport = () => + typeof window !== 'undefined' && window.innerWidth <= MOBILE_BREAKPOINT; + +const useIsMobileViewport = () => { + const [isMobileViewport, setIsMobileViewport] = useState(getIsMobileViewport); + + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return undefined; + + const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); + const handleChange = (event) => setIsMobileViewport(event.matches); + + setIsMobileViewport(mediaQuery.matches); + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', handleChange); + } else { + mediaQuery.addListener(handleChange); + } + + return () => { + if (mediaQuery.removeEventListener) { + mediaQuery.removeEventListener('change', handleChange); + } else { + mediaQuery.removeListener(handleChange); + } + }; + }, []); + + return isMobileViewport; +}; + +export default useIsMobileViewport; diff --git a/packages/react/src/hooks/useLockBodyScroll.js b/packages/react/src/hooks/useLockBodyScroll.js new file mode 100644 index 0000000000..22f4de7539 --- /dev/null +++ b/packages/react/src/hooks/useLockBodyScroll.js @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; + +const useLockBodyScroll = (locked) => { + useEffect(() => { + if (!locked || typeof document === 'undefined') return undefined; + + const bodyStyle = document.body.style; + const previousOverflow = bodyStyle.overflow; + const previousPaddingRight = bodyStyle.paddingRight; + const scrollbarWidth = + typeof window === 'undefined' + ? 0 + : window.innerWidth - document.documentElement.clientWidth; + + bodyStyle.overflow = 'hidden'; + if (scrollbarWidth > 0) { + bodyStyle.paddingRight = `${scrollbarWidth}px`; + } + + return () => { + bodyStyle.overflow = previousOverflow; + bodyStyle.paddingRight = previousPaddingRight; + }; + }, [locked]); +}; + +export default useLockBodyScroll; diff --git a/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js b/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js index a68dc340f6..e6af0d9ae2 100644 --- a/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js +++ b/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef } from 'react'; import { css } from '@emotion/react'; import { Box, @@ -16,6 +16,9 @@ import VideoMessageRecorder from './VideoMessageRecoder'; import { getChatInputFormattingToolbarStyles } from './ChatInput.styles'; import formatSelection from '../../lib/formatSelection'; import InsertLinkToolBox from './InsertLinkToolBox'; +import useIsMobileViewport, { + MOBILE_BREAKPOINT, +} from '../../hooks/useIsMobileViewport'; const ChatInputFormattingToolbar = ({ messageRef, @@ -45,6 +48,7 @@ const ChatInputFormattingToolbar = ({ const isRecordingMessage = useMessageStore( (state) => state.isRecordingMessage ); + const isMobileViewport = useIsMobileViewport(); const [isEmojiOpen, setEmojiOpen] = useState(false); const [isInsertLinkOpen, setInsertLinkOpen] = useState(false); @@ -58,6 +62,14 @@ const ChatInputFormattingToolbar = ({ formatSelection(messageRef, item.pattern); setPopoverOpen(false); }; + const openEmojiPicker = () => { + if (isMobileViewport) { + messageRef.current?.blur?.(); + } + + setPopoverOpen(false); + setEmojiOpen(true); + }; const handleEmojiClick = (emojiEvent) => { const [emoji] = emojiEvent.names; const message = `${messageRef.current.value} :${emoji.replace( @@ -92,7 +104,7 @@ const ChatInputFormattingToolbar = ({ disabled={isRecordingMessage} onClick={() => { if (isRecordingMessage) return; - setEmojiOpen(true); + openEmojiPicker(); }} > @@ -106,7 +118,7 @@ const ChatInputFormattingToolbar = ({ disabled={isRecordingMessage} onClick={() => { if (isRecordingMessage) return; - setEmojiOpen(true); + openEmojiPicker(); }} > @@ -249,7 +261,7 @@ const ChatInputFormattingToolbar = ({ setEmojiOpen(false)} + useMobileBottomSheet={isMobileViewport} positionStyles={css` position: absolute; bottom: 7rem; diff --git a/packages/react/src/views/EmojiPicker/EmojiPicker.js b/packages/react/src/views/EmojiPicker/EmojiPicker.js index 6501eb8320..3d5d9a24d5 100644 --- a/packages/react/src/views/EmojiPicker/EmojiPicker.js +++ b/packages/react/src/views/EmojiPicker/EmojiPicker.js @@ -1,10 +1,66 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import EmojiPicker from 'emoji-picker-react'; import { css } from '@emotion/react'; import PropTypes from 'prop-types'; -import { Box, Popup, useTheme } from '@embeddedchat/ui-elements'; +import { + Box, + Icon, + Popup, + ReactPortal, + useTheme, +} from '@embeddedchat/ui-elements'; +import useIsMobileViewport from '../../hooks/useIsMobileViewport'; +import useLockBodyScroll from '../../hooks/useLockBodyScroll'; import getEmojiPickerStyles from './EmojiPicker.styles'; +const MobileEmojiSheet = ({ children, onClose, styles }) => { + const sheetRef = useRef(null); + + useEffect(() => { + sheetRef.current?.focus(); + }, []); + + return ( + { + if (event.key === 'Escape') onClose(); + }} + onClick={(event) => event.stopPropagation()} + > + + + + + + {children} + + ); +}; + +MobileEmojiSheet.propTypes = { + children: PropTypes.node.isRequired, + onClose: PropTypes.func.isRequired, + styles: PropTypes.shape({ + emojiPicker: PropTypes.object, + mobileEmojiPicker: PropTypes.object, + mobileSheet: PropTypes.object, + mobileSheetHeader: PropTypes.object, + mobileSheetClose: PropTypes.object, + }).isRequired, +}; + const CustomEmojiPicker = ({ handleEmojiClick, positionStyles = css` @@ -14,15 +70,56 @@ const CustomEmojiPicker = ({ `, wrapperId = 'emoji-popup', onClose = () => {}, + useMobileBottomSheet = false, }) => { const theme = useTheme(); const styles = getEmojiPickerStyles(theme); + const isMobileViewport = useIsMobileViewport(); + const isMobileBottomSheet = useMobileBottomSheet && isMobileViewport; + const returnFocusRef = useRef(null); const previewConfig = { defaultEmoji: '1f60d', defaultCaption: 'None', showPreview: true, }; + useLockBodyScroll(isMobileBottomSheet); + + useEffect(() => { + if (!isMobileBottomSheet || typeof document === 'undefined') { + return undefined; + } + + returnFocusRef.current = document.activeElement; + + return () => { + if (returnFocusRef.current?.focus) { + returnFocusRef.current.focus({ preventScroll: true }); + } + returnFocusRef.current = null; + }; + }, [isMobileBottomSheet]); + + if (isMobileBottomSheet) { + return ( + + + + + + + ); + } + return ( { background: ${theme.colors.primary}; } `, + mobileSheetBackdrop: css` + position: fixed; + inset: 0; + z-index: ${(theme.zIndex?.modal || 1500) - 1}; + background: ${alpha(theme.colors.foreground, 0.08)}; + animation: ec-emoji-sheet-backdrop-in 180ms ease-out; + + @keyframes ec-emoji-sheet-backdrop-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + } + `, + mobileSheet: css` + position: fixed; + left: 0.5rem; + right: 0.5rem; + bottom: 0.5rem; + z-index: ${theme.zIndex?.modal || 1500}; + display: flex; + flex-direction: column; + height: min(28rem, calc(100vh - 4rem)); + max-height: min(28rem, calc(100vh - 4rem)); + overflow: hidden; + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radius}; + background: ${theme.colors.background}; + box-shadow: ${theme.shadows[2]}; + padding-bottom: env(safe-area-inset-bottom, 0); + animation: ec-emoji-sheet-slide-up 180ms ease-out; + + @keyframes ec-emoji-sheet-slide-up { + from { + opacity: 0; + transform: translateY(1rem); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + } + + @supports (height: 100dvh) { + height: min(28rem, calc(100dvh - 4rem)); + max-height: min(28rem, calc(100dvh - 4rem)); + } + `, + mobileSheetHeader: css` + display: flex; + flex: 0 0 2.5rem; + align-items: center; + justify-content: flex-end; + padding: 0 0.5rem; + `, + mobileSheetClose: css` + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border: 0; + border-radius: ${theme.radius}; + background: transparent; + color: ${theme.colors.foreground}; + cursor: pointer; + + &:hover, + &:focus-visible { + background: ${calculatedColors}; + } + `, + mobileEmojiPicker: css` + min-height: 0; + height: auto; + flex: 1 1 auto; + + .EmojiPickerReact { + width: 100% !important; + height: 100% !important; + border: none; + border-radius: inherit; + } + `, }; return styles;