diff --git a/.changeset/dark-pots-tell.md b/.changeset/dark-pots-tell.md new file mode 100644 index 0000000000..9dde4f187d --- /dev/null +++ b/.changeset/dark-pots-tell.md @@ -0,0 +1,6 @@ +--- +'@lg-chat/message-feed': minor +--- + +- [LG-5932](https://jira.mongodb.org/browse/LG-5932): Refactor to use `CompoundComponent` pattern +- [LG-5934](https://jira.mongodb.org/browse/LG-5934): add `MessageFeedProvider` and `useMessageFeedContext` \ No newline at end of file diff --git a/chat/message-feed/package.json b/chat/message-feed/package.json index 6769451383..f5c14b60ee 100644 --- a/chat/message-feed/package.json +++ b/chat/message-feed/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@leafygreen-ui/button": "workspace:^", + "@leafygreen-ui/compound-component": "workspace:^", "@leafygreen-ui/emotion": "workspace:^", "@leafygreen-ui/icon": "workspace:^", "@leafygreen-ui/lib": "workspace:^", diff --git a/chat/message-feed/src/MessageFeed/MessageFeed.tsx b/chat/message-feed/src/MessageFeed/MessageFeed.tsx index fedd7ce2b5..4668d2bbc5 100644 --- a/chat/message-feed/src/MessageFeed/MessageFeed.tsx +++ b/chat/message-feed/src/MessageFeed/MessageFeed.tsx @@ -8,6 +8,7 @@ import React, { } from 'react'; import { useInView } from 'react-intersection-observer'; +import { CompoundComponent } from '@leafygreen-ui/compound-component'; import LeafyGreenProvider, { useDarkMode, } from '@leafygreen-ui/leafygreen-provider'; @@ -21,109 +22,118 @@ import { } from './MessageFeed.styles'; import { MessageFeedProps } from '.'; -export const MessageFeed = forwardRef( - ( - { children, darkMode: darkModeProp, className, ...rest }: MessageFeedProps, - ref: ForwardedRef, - ) => { - const { darkMode, theme } = useDarkMode(darkModeProp); - - const scrollContainerRef = useRef(null); - const scrollTimerRef = useRef | null>(null); - - const [showScrollButton, setShowScrollButton] = useState(false); - - const { ref: topInterceptRef, inView: isTopInView } = useInView({ - initialInView: true, - root: scrollContainerRef.current, - threshold: 0, - }); - const { ref: bottomInterceptRef, inView: isBottomInView } = useInView({ - initialInView: true, - root: scrollContainerRef.current, - threshold: 0, - }); - - const scrollToLatest = useCallback(() => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo( - 0, - scrollContainerRef.current.scrollHeight, - ); - } - }, []); - - useEffect(() => { - const scrollElement = scrollContainerRef.current; - if (!scrollElement) return; - - const isScrolledToEnd = () => { - if (!scrollContainerRef.current) return true; - const { scrollHeight, scrollTop, clientHeight } = - scrollContainerRef.current; - // Add a small buffer (2px) to account for floating point differences - return scrollHeight - scrollTop - clientHeight <= 2; - }; - - // Handle scroll events - const handleScroll = () => { - // Clear any existing timeout - if (scrollTimerRef.current) { - clearTimeout(scrollTimerRef.current); +export const MessageFeed = CompoundComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ( + { + children, + darkMode: darkModeProp, + className, + ...rest + }: MessageFeedProps, + ref: ForwardedRef, + ) => { + const { darkMode, theme } = useDarkMode(darkModeProp); + + const scrollContainerRef = useRef(null); + const scrollTimerRef = useRef | null>(null); + + const [showScrollButton, setShowScrollButton] = useState(false); + + const { ref: topInterceptRef, inView: isTopInView } = useInView({ + initialInView: true, + root: scrollContainerRef.current, + threshold: 0, + }); + const { ref: bottomInterceptRef, inView: isBottomInView } = useInView({ + initialInView: true, + root: scrollContainerRef.current, + threshold: 0, + }); + + const scrollToLatest = useCallback(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo( + 0, + scrollContainerRef.current.scrollHeight, + ); } - - // Wait until scroll animation completes This avoids a brief flicker - // when the user scrolls to the bottom - const scrollDuration = 100; - scrollTimerRef.current = setTimeout(() => { - setShowScrollButton(!isScrolledToEnd()); - }, scrollDuration); - }; - - scrollElement.addEventListener('scroll', handleScroll); - - return () => { - scrollElement.removeEventListener('scroll', handleScroll); - if (scrollTimerRef.current) { - clearTimeout(scrollTimerRef.current); + }, []); + + useEffect(() => { + const scrollElement = scrollContainerRef.current; + if (!scrollElement) return; + + const isScrolledToEnd = () => { + if (!scrollContainerRef.current) return true; + const { scrollHeight, scrollTop, clientHeight } = + scrollContainerRef.current; + // Add a small buffer (2px) to account for floating point differences + return scrollHeight - scrollTop - clientHeight <= 2; + }; + + // Handle scroll events + const handleScroll = () => { + // Clear any existing timeout + if (scrollTimerRef.current) { + clearTimeout(scrollTimerRef.current); + } + + // Wait until scroll animation completes This avoids a brief flicker + // when the user scrolls to the bottom + const scrollDuration = 100; + scrollTimerRef.current = setTimeout(() => { + setShowScrollButton(!isScrolledToEnd()); + }, scrollDuration); + }; + + scrollElement.addEventListener('scroll', handleScroll); + + return () => { + scrollElement.removeEventListener('scroll', handleScroll); + if (scrollTimerRef.current) { + clearTimeout(scrollTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!showScrollButton) { + scrollToLatest(); } - }; - }, []); - - useEffect(() => { - if (!showScrollButton) { - scrollToLatest(); - } - }, [children, showScrollButton, scrollToLatest]); - - return ( - -
-
- {/* Empty span element used to track if container can scroll up */} - - {children} - {/* Empty span element used to track if container can scroll down */} - + }, [children, showScrollButton, scrollToLatest]); + + return ( + +
+
+ {/* Empty span element used to track if container can scroll up */} + + {children} + {/* Empty span element used to track if container can scroll down */} + +
+
- -
- - ); + + ); + }, + ), + { + displayName: 'MessageFeed', }, ); - -MessageFeed.displayName = 'MessageFeed'; diff --git a/chat/message-feed/src/MessageFeedContext/MessageFeedContext.spec.tsx b/chat/message-feed/src/MessageFeedContext/MessageFeedContext.spec.tsx new file mode 100644 index 0000000000..55bdbb9bc9 --- /dev/null +++ b/chat/message-feed/src/MessageFeedContext/MessageFeedContext.spec.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; + +import { + MessageFeedProvider, + useMessageFeedContext, +} from './MessageFeedContext'; +import { MessageFeedProviderProps } from './MessageFeedContext.types'; + +const renderMessageFeedProvider = ( + props?: Partial, +) => { + const defaultProps: MessageFeedProviderProps = { + shouldHideInitialMessage: false, + }; + + const { result } = renderHook(() => useMessageFeedContext(), { + wrapper: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + }); + + return { result }; +}; +describe('useMessageFeedContext', () => { + test('should return the correct context', () => { + const { result } = renderMessageFeedProvider(); + expect(result.current.shouldHideInitialMessage).toBe(false); + }); + + test('should return the correct context when props are provided', () => { + const { result } = renderMessageFeedProvider({ + shouldHideInitialMessage: true, + }); + expect(result.current.shouldHideInitialMessage).toBe(true); + }); + + test('should throw an error when used outside the provider', () => { + /** + * The version of `renderHook` imported from "@testing-library/react-hooks" (used in React 17) + * has an error boundary, and doesn't throw errors as expected: + * https://github.com/testing-library/react-hooks-testing-library/blob/main/src/index.ts#L5 + */ + if (isReact17()) { + const { result } = renderHook(() => useMessageFeedContext()); + expect(result.error.message).toEqual( + 'useMessageFeedContext must be used within a MessageFeedProvider', + ); + } else { + expect(() => { + renderHook(() => useMessageFeedContext()); + }).toThrow( + 'useMessageFeedContext must be used within a MessageFeedProvider', + ); + } + }); +}); diff --git a/chat/message-feed/src/MessageFeedContext/MessageFeedContext.tsx b/chat/message-feed/src/MessageFeedContext/MessageFeedContext.tsx new file mode 100644 index 0000000000..101beb4999 --- /dev/null +++ b/chat/message-feed/src/MessageFeedContext/MessageFeedContext.tsx @@ -0,0 +1,33 @@ +import React, { createContext, useContext } from 'react'; + +import { + MessageFeedContextType, + MessageFeedProviderProps, +} from './MessageFeedContext.types'; + +export const MessageFeedContext = createContext( + null, +); + +export const MessageFeedProvider = ({ + children, + shouldHideInitialMessage = false, +}: MessageFeedProviderProps) => { + return ( + + {children} + + ); +}; + +export const useMessageFeedContext = () => { + const context = useContext(MessageFeedContext); + + if (!context) { + throw new Error( + 'useMessageFeedContext must be used within a MessageFeedProvider', + ); + } + + return context; +}; diff --git a/chat/message-feed/src/MessageFeedContext/MessageFeedContext.types.ts b/chat/message-feed/src/MessageFeedContext/MessageFeedContext.types.ts new file mode 100644 index 0000000000..7a19beb1a7 --- /dev/null +++ b/chat/message-feed/src/MessageFeedContext/MessageFeedContext.types.ts @@ -0,0 +1,8 @@ +import { PropsWithChildren } from 'react'; + +export interface MessageFeedContextType { + shouldHideInitialMessage: boolean; +} + +export interface MessageFeedProviderProps + extends PropsWithChildren {} diff --git a/chat/message-feed/src/MessageFeedContext/index.ts b/chat/message-feed/src/MessageFeedContext/index.ts new file mode 100644 index 0000000000..9fbbb90ecc --- /dev/null +++ b/chat/message-feed/src/MessageFeedContext/index.ts @@ -0,0 +1,8 @@ +export { + MessageFeedProvider, + useMessageFeedContext, +} from './MessageFeedContext'; +export type { + MessageFeedContextType, + MessageFeedProviderProps, +} from './MessageFeedContext.types'; diff --git a/chat/message-feed/src/shared.types.ts b/chat/message-feed/src/shared.types.ts new file mode 100644 index 0000000000..cfff07008a --- /dev/null +++ b/chat/message-feed/src/shared.types.ts @@ -0,0 +1,13 @@ +/** + * Static property names used to identify MessageFeed compound components. + * These are implementation details for the compound component pattern and should not be exported. + */ +export const MessageFeedSubcomponentProperty = { + InitialMessage: 'isLGMessageFeedInitialMessage', +} as const; + +/** + * Type representing the possible static property names for MessageFeed subcomponents. + */ +export type MessageFeedSubcomponentProperty = + (typeof MessageFeedSubcomponentProperty)[keyof typeof MessageFeedSubcomponentProperty]; diff --git a/chat/message-feed/tsconfig.json b/chat/message-feed/tsconfig.json index 711bfc73d2..54ed514790 100644 --- a/chat/message-feed/tsconfig.json +++ b/chat/message-feed/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../packages/button" }, + { + "path": "../../packages/compound-component" + }, { "path": "../../packages/emotion" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd14a209e1..8d4f00c145 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -647,6 +647,9 @@ importers: '@leafygreen-ui/button': specifier: workspace:^ version: link:../../packages/button + '@leafygreen-ui/compound-component': + specifier: workspace:^ + version: link:../../packages/compound-component '@leafygreen-ui/emotion': specifier: workspace:^ version: link:../../packages/emotion