From bf042fb22dbb38cd57e23e5f90a972f17659a753 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Fri, 27 Dec 2024 17:54:00 +0100 Subject: [PATCH] Fixing some styling problems and add support for mobile --- apps/site/src/App.tsx | 6 +- lib/components/chat/chat.tsx | 55 ++++++++++++++----- lib/components/chat/styles/chat.styles.ts | 19 ++++++- lib/components/message/messages.tsx | 8 +-- .../message/styles/messages.styles.ts | 3 + lib/components/sidebar/hooks/useSidebar.ts | 9 --- .../sidebar/hooks/useSidebarHandler.ts | 19 +++++++ lib/components/sidebar/sidebar.tsx | 7 ++- .../sidebar/styles/sidebar.styles.ts | 17 +++++- lib/hooks/useMediaQuery.ts | 21 +++++++ lib/provider/chat-provider.tsx | 7 ++- lib/provider/reducer.ts | 10 ++++ lib/provider/types.ts | 5 +- package.json | 2 +- tests/components/chat/chat.test.tsx | 5 +- .../message/hooks/useMessageInput.test.tsx | 2 +- tests/components/message/messages.test.tsx | 3 - tests/components/sidebar/sidebar.test.tsx | 7 ++- tests/provider/chat-provider.test.tsx | 6 +- 19 files changed, 160 insertions(+), 51 deletions(-) create mode 100644 lib/components/sidebar/hooks/useSidebarHandler.ts create mode 100644 lib/hooks/useMediaQuery.ts diff --git a/apps/site/src/App.tsx b/apps/site/src/App.tsx index 8da85d2..3ca73f8 100644 --- a/apps/site/src/App.tsx +++ b/apps/site/src/App.tsx @@ -67,11 +67,7 @@ function App() { return (
- + {(item, key) => ( = ({ children, className, classNames, }) => { - const { sidebar, messageInput, messages } = useChat(children); - const classes = useClassNames({ className, classNames }); - return ( - - - {sidebar} - - {messages} - {messageInput} - - - + + {children} + ); }; export default Chat; + +const ChatHelper: React.FC = ({ + children, + className, + classNames, +}) => { + const { sidebar, messageInput, messages } = useChat(children); + const classes = useClassNames({ className, classNames }); + const isMobile = useMediaQuery("(max-width: 768px)"); + const { handleClose } = useSidebarHandler(); + const { + state: { isSidebarOpen }, + } = useChatProvider(); + + return ( + + + {sidebar} + {isMobile && isSidebarOpen && ( + + )} + + {messages} + {messageInput} + + + + ); +}; diff --git a/lib/components/chat/styles/chat.styles.ts b/lib/components/chat/styles/chat.styles.ts index 1842ef7..d15cabc 100644 --- a/lib/components/chat/styles/chat.styles.ts +++ b/lib/components/chat/styles/chat.styles.ts @@ -1,19 +1,22 @@ import styled from "styled-components"; +import { withDevClassName } from "../../../utils"; -export const ChatWrapper = styled.div` +export const ChatWrapper = styled.div.attrs(withDevClassName("chat-wrapper"))` display: flex; flex-direction: column; height: 100%; width: 100%; `; -export const ChatContent = styled.div` +export const ChatContent = styled.div.attrs(withDevClassName("chat-content"))` display: flex; flex: 1; overflow: hidden; `; -export const ChatContainer = styled.div` +export const ChatContainer = styled.div.attrs( + withDevClassName("chat-container") +)` display: flex; flex-direction: column; flex: 1; @@ -22,3 +25,13 @@ export const ChatContainer = styled.div` overflow-y: auto; background-color: #ffffff; `; + +export const ChatLayer = styled.div.attrs(withDevClassName("chat-layer"))` + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + z-index: 20; + background-color: rgba(0, 0, 0, 0.4); +`; diff --git a/lib/components/message/messages.tsx b/lib/components/message/messages.tsx index a4c9f26..490c545 100644 --- a/lib/components/message/messages.tsx +++ b/lib/components/message/messages.tsx @@ -9,7 +9,7 @@ import { useScrollToBottom } from "./hooks/useScrollToBottom"; import useClassNames from "../../hooks/useClassNames"; import { useChatProvider } from "../../provider"; -export const Messages = ({ +export function Messages({ items = [], children, loadingContent, @@ -17,8 +17,8 @@ export const Messages = ({ className, classNames, headerContent, -}: MessagesProps) => { - const bottomRef = useScrollToBottom([children, isLoading]); +}: MessagesProps) { + const bottomRef = useScrollToBottom([items, isLoading]); const { state: { isSidebarOpen }, } = useChatProvider(); @@ -55,6 +55,6 @@ export const Messages = ({ ); -}; +} Messages.displayName = "Messages"; diff --git a/lib/components/message/styles/messages.styles.ts b/lib/components/message/styles/messages.styles.ts index 4fa197c..a887a1a 100644 --- a/lib/components/message/styles/messages.styles.ts +++ b/lib/components/message/styles/messages.styles.ts @@ -9,7 +9,9 @@ const BaseMessageWrapper = styled.div.attrs( align-items: flex-start; gap: 0.5rem; max-width: 45rem; + width: 100%; margin: 0 auto; + padding: 0 1rem; `; export const UserMessageWrapper = styled(BaseMessageWrapper).attrs( @@ -62,6 +64,7 @@ export const AssistantMessageContent = styled(BaseMessageContent).attrs( withDevClassName("assistant-message-content") )` color: #000000; + white-space: normal; `; const pulse = keyframes` diff --git a/lib/components/sidebar/hooks/useSidebar.ts b/lib/components/sidebar/hooks/useSidebar.ts index 7377b83..87c6096 100644 --- a/lib/components/sidebar/hooks/useSidebar.ts +++ b/lib/components/sidebar/hooks/useSidebar.ts @@ -1,11 +1,9 @@ import { ReactNode, isValidElement, useMemo } from "react"; -import { useChatProvider } from "../../../provider"; import { SidebarItem } from "../sidebar-item"; import { mRound } from "../../../utils"; type UseSidebarReturn = { renderItems: () => ReactNode; - handleToggle: () => any; size: number; }; @@ -25,8 +23,6 @@ export const useSidebar = ({ children, size, }: UseSidebarProps): UseSidebarReturn => { - const { dispatch } = useChatProvider(); - const remSize = useMemo( () => (size ? mRound(size / 16, 0.25) : 16), [size] @@ -54,13 +50,8 @@ export const useSidebar = ({ return children as ReactNode; }; - const handleToggle = () => { - dispatch({ type: "TOGGLE_SIDEBAR" }); - }; - return { size: remSize, renderItems, - handleToggle, }; }; diff --git a/lib/components/sidebar/hooks/useSidebarHandler.ts b/lib/components/sidebar/hooks/useSidebarHandler.ts new file mode 100644 index 0000000..b819962 --- /dev/null +++ b/lib/components/sidebar/hooks/useSidebarHandler.ts @@ -0,0 +1,19 @@ +import { useChatProvider } from "../../../provider"; + +export const useSidebarHandler = () => { + const { dispatch } = useChatProvider(); + + const handleToggle = () => { + dispatch({ type: "TOGGLE_SIDEBAR" }); + }; + + const handleOpen = () => { + dispatch({ type: "OPEN_SIDEBAR" }); + }; + + const handleClose = () => { + dispatch({ type: "CLOSE_SIDEBAR" }); + }; + + return { handleToggle, handleOpen, handleClose }; +}; diff --git a/lib/components/sidebar/sidebar.tsx b/lib/components/sidebar/sidebar.tsx index 8a478d3..e0976f0 100644 --- a/lib/components/sidebar/sidebar.tsx +++ b/lib/components/sidebar/sidebar.tsx @@ -12,6 +12,8 @@ import { useSidebar } from "./hooks/useSidebar"; import { MenuIcon } from "../icons"; import { useChatProvider } from "../../provider"; import useClassNames from "../../hooks/useClassNames"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useSidebarHandler } from "./hooks/useSidebarHandler"; export const Sidebar = ({ items = [], @@ -23,15 +25,17 @@ export const Sidebar = ({ className, classNames, }: SidebarProps) => { - const { renderItems, handleToggle, size } = useSidebar({ + const { renderItems, size } = useSidebar({ items, children, size: pixelSize, }); + const { handleToggle } = useSidebarHandler(); const { state: { isSidebarOpen }, } = useChatProvider(); const classes = useClassNames({ className, classNames }); + const isMobile = useMediaQuery("(max-width: 768px)"); return ( @@ -48,6 +52,7 @@ export const Sidebar = ({ className={classes.container} $isOpen={isSidebarOpen} $size={size} + $isMobile={isMobile} > ` +)<{ $isOpen?: boolean; $size?: number; $isMobile?: boolean }>` width: ${({ $isOpen, $size = 16 }) => ($isOpen ? `${$size}rem` : "0")}; background: #f9f9f9; overflow: hidden; - transition: width 0.3s ease; + transition: width ${({ $isMobile }) => ($isMobile ? "0.1s" : "0.3s")} ease; display: flex; flex-direction: column; height: 100%; + ${({ $isMobile }) => + $isMobile && + ` + position: absolute; + z-index: 30; + top: 0; + left: 0; + right: 0; + bottom: 0; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + `} `; export const SidebarContent = styled.div.attrs( @@ -70,7 +81,7 @@ export const ToggleButton = styled.button.attrs( position: absolute; top: 0.75rem; left: 1rem; - z-index: 20; + z-index: 40; transform: ${({ $isOpen }) => $isOpen ? "rotate(-180deg)" : "rotate(0deg)"}; transition: all 0.3s ease; diff --git a/lib/hooks/useMediaQuery.ts b/lib/hooks/useMediaQuery.ts new file mode 100644 index 0000000..918a695 --- /dev/null +++ b/lib/hooks/useMediaQuery.ts @@ -0,0 +1,21 @@ +import React from "react"; + +export function useMediaQuery(query: string) { + const subscribe = React.useCallback( + (callback: () => void) => { + const matchMedia = window.matchMedia(query); + + matchMedia.addEventListener("change", callback); + return () => { + matchMedia.removeEventListener("change", callback); + }; + }, + [query] + ); + + const getSnapshot = () => { + return window.matchMedia(query).matches; + }; + + return React.useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/lib/provider/chat-provider.tsx b/lib/provider/chat-provider.tsx index cdae5ec..7652cd8 100644 --- a/lib/provider/chat-provider.tsx +++ b/lib/provider/chat-provider.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useReducer, ReactNode } from "react"; import { ChatProviderContextType } from "./types"; import { chatReducer, initialState } from "./reducer"; +import { useMediaQuery } from "../hooks/useMediaQuery"; const ChatContext = createContext( undefined @@ -19,7 +20,11 @@ type ChatProviderProps = { }; export const ChatProvider: React.FC = ({ children }) => { - const [state, dispatch] = useReducer(chatReducer, initialState); + const isMobile = useMediaQuery("(max-width: 768px)"); + const [state, dispatch] = useReducer(chatReducer, { + ...initialState, + isSidebarOpen: !isMobile, + }); return ( diff --git a/lib/provider/reducer.ts b/lib/provider/reducer.ts index a4ed53b..0e210e8 100644 --- a/lib/provider/reducer.ts +++ b/lib/provider/reducer.ts @@ -14,6 +14,16 @@ export const chatReducer = ( ...state, isSidebarOpen: !state.isSidebarOpen, }; + case "OPEN_SIDEBAR": + return { + ...state, + isSidebarOpen: true, + }; + case "CLOSE_SIDEBAR": + return { + ...state, + isSidebarOpen: false, + }; default: return state; } diff --git a/lib/provider/types.ts b/lib/provider/types.ts index 72ac32a..2ab5f23 100644 --- a/lib/provider/types.ts +++ b/lib/provider/types.ts @@ -2,7 +2,10 @@ export type ChatState = { isSidebarOpen: boolean; }; -export type ChatAction = { type: "TOGGLE_SIDEBAR" }; +export type ChatAction = + | { type: "TOGGLE_SIDEBAR" } + | { type: "OPEN_SIDEBAR" } + | { type: "CLOSE_SIDEBAR" }; export type ChatProviderContextType = { state: ChatState; diff --git a/package.json b/package.json index 9fabcf5..c1f28ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@funkyoz/react-chat", - "version": "1.0.3", + "version": "1.0.4", "type": "module", "main": "dist/main.js", "types": "dist/main.d.ts", diff --git a/tests/components/chat/chat.test.tsx b/tests/components/chat/chat.test.tsx index 11b53bc..7d38c1a 100644 --- a/tests/components/chat/chat.test.tsx +++ b/tests/components/chat/chat.test.tsx @@ -6,7 +6,10 @@ import { Sidebar } from "../../../lib/components/sidebar/sidebar"; import { MessageInput } from "../../../lib/components/message/message-input"; import { Messages } from "../../../lib/components/message/messages"; -// Mock the components +vi.mock("../../../lib/hooks/useMediaQuery", () => ({ + useMediaQuery: vi.fn(() => false), +})); + vi.mock("../../../lib/components/sidebar/sidebar", () => ({ Sidebar: ({ children, diff --git a/tests/components/message/hooks/useMessageInput.test.tsx b/tests/components/message/hooks/useMessageInput.test.tsx index edfb8bf..2068b21 100644 --- a/tests/components/message/hooks/useMessageInput.test.tsx +++ b/tests/components/message/hooks/useMessageInput.test.tsx @@ -6,7 +6,7 @@ import { useMessageInput } from "../../../../lib/components/message/hooks/useMes // Mock ChatProvider context vi.mock("../../../../lib/provider", () => ({ useChatProvider: () => ({ - state: { withAutoFocus: false }, + state: { isSidebarOpen: true }, }), })); diff --git a/tests/components/message/messages.test.tsx b/tests/components/message/messages.test.tsx index c960c06..57f8af2 100644 --- a/tests/components/message/messages.test.tsx +++ b/tests/components/message/messages.test.tsx @@ -32,9 +32,6 @@ vi.mock("../../../lib/provider", () => ({ useChatProvider: vi.fn(() => ({ state: { isSidebarOpen: true, - isDarkMode: false, - withAutoFocus: false, - assistantIcon: null, }, dispatch: vi.fn(), })), diff --git a/tests/components/sidebar/sidebar.test.tsx b/tests/components/sidebar/sidebar.test.tsx index b772f00..ac92071 100644 --- a/tests/components/sidebar/sidebar.test.tsx +++ b/tests/components/sidebar/sidebar.test.tsx @@ -9,14 +9,15 @@ vi.mock("../../../lib/provider", () => ({ useChatProvider: vi.fn(() => ({ state: { isSidebarOpen: true, - isDarkMode: false, - withAutoFocus: false, - assistantIcon: null, }, dispatch: vi.fn(), })), })); +vi.mock("../../../lib/hooks/useMediaQuery", () => ({ + useMediaQuery: vi.fn(() => false), +})); + // Mock the icons vi.mock("../../../lib/components/icons", () => ({ MenuIcon: () =>
Menu Icon
, diff --git a/tests/provider/chat-provider.test.tsx b/tests/provider/chat-provider.test.tsx index 5aad6ea..d65d5ab 100644 --- a/tests/provider/chat-provider.test.tsx +++ b/tests/provider/chat-provider.test.tsx @@ -1,8 +1,12 @@ import React from "react"; import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { ChatProvider } from "../../lib/provider/chat-provider"; +vi.mock("../../lib/hooks/useMediaQuery", () => ({ + useMediaQuery: vi.fn(() => false), +})); + describe("ChatProvider", () => { it("should render children", () => { render(