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;