diff --git a/packages/extension/src/companion/CompanionDiscussion.tsx b/packages/extension/src/companion/CompanionDiscussion.tsx index 0510f1c223d..d5ee0c8c7e4 100644 --- a/packages/extension/src/companion/CompanionDiscussion.tsx +++ b/packages/extension/src/companion/CompanionDiscussion.tsx @@ -10,7 +10,12 @@ import { useBackgroundRequest } from '@dailydotdev/shared/src/hooks/companion'; import { generateCommentsQueryKey } from '@dailydotdev/shared/src/lib/query'; import { getCompanionWrapper } from '@dailydotdev/shared/src/lib/extension'; import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; -import CommentInputOrModal from '@dailydotdev/shared/src/components/comments/CommentInputOrModal'; +import type { CommentInputProps } from '@dailydotdev/shared/src/components/comments/CommentInput'; +import CommentInput from '@dailydotdev/shared/src/components/comments/CommentInput'; + +const CompanionCommentInput = (props: CommentInputProps): ReactElement => ( + +); interface CompanionDiscussionProps { post: PostBootData; @@ -24,8 +29,7 @@ export function CompanionDiscussion({ style, className, onShowUpvoted, -}: CompanionDiscussionProps): ReactElement { - const commentClasses = { tab: '!min-h-[14.5rem]' }; +}: CompanionDiscussionProps): ReactElement | null { const { openShareComment } = useShareComment(Origin.Companion); useBackgroundRequest( generateCommentsQueryKey({ postId: post?.id, sortBy: undefined }), @@ -49,11 +53,8 @@ export function CompanionDiscussion({ openShareComment(comment, post)} onClickUpvote={onShowUpvoted} modalParentSelector={getCompanionWrapper} - className={commentClasses} /> diff --git a/packages/shared/src/components/comments/CommentInput.spec.tsx b/packages/shared/src/components/comments/CommentInput.spec.tsx new file mode 100644 index 00000000000..a1ecc3fef91 --- /dev/null +++ b/packages/shared/src/components/comments/CommentInput.spec.tsx @@ -0,0 +1,103 @@ +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; +import type { Post } from '../../graphql/posts'; +import { useViewSize } from '../../hooks'; +import CommentInput from './CommentInput'; + +const mockComposerProps = jest.fn(); +const mockDrawerProps = jest.fn(); + +jest.mock('../../hooks', () => { + const actual = jest.requireActual('../../hooks'); + + return { + ...actual, + useViewSize: jest.fn(), + }; +}); + +jest.mock('../../hooks/post/useMutateComment', () => ({ + useMutateComment: () => ({ + mutateComment: jest.fn(), + isLoading: false, + isSuccess: false, + }), +})); + +jest.mock('../fields/MarkdownInput/CommentMarkdownInput', () => ({ + CommentMarkdownInput: (props: Record) => { + mockComposerProps(props); + return composer; + }, +})); + +jest.mock('../drawers/Drawer', () => { + const actual = jest.requireActual('../drawers/Drawer'); + + return { + ...actual, + Drawer: ({ children, ...props }: React.PropsWithChildren) => { + mockDrawerProps(props); + return {children}; + }, + }; +}); + +const post = { id: 'post-1', source: { id: 'source-1' } } as Post; + +describe('CommentInput', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('opens as a full-screen drawer on mobile, focused and filling it', () => { + jest.mocked(useViewSize).mockReturnValue(false); + render(); + + expect(screen.getByText('composer')).toBeInTheDocument(); + expect(mockDrawerProps).toHaveBeenCalledWith( + expect.objectContaining({ + isFullScreen: true, + appendOnRoot: true, + }), + ); + expect(mockComposerProps).toHaveBeenCalledWith( + expect.objectContaining({ fills: true, autoFocus: true }), + ); + }); + + it('drops the drawer default padding so content is not padded twice', () => { + // Including the bottom: the composer's action bar carries the safe area. + jest.mocked(useViewSize).mockReturnValue(false); + render(); + + const { className } = mockDrawerProps.mock.calls[0][0]; + expect(className.wrapper).toContain('!p-0'); + }); + + it('keeps the draft when crossing the breakpoint remounts the editor', () => { + jest.mocked(useViewSize).mockReturnValue(true); + const { rerender } = render(); + + const { onChange } = mockComposerProps.mock.calls.at(-1)[0]; + act(() => onChange('my draft')); + + jest.mocked(useViewSize).mockReturnValue(false); + rerender(); + + expect(mockComposerProps).toHaveBeenLastCalledWith( + expect.objectContaining({ initialContent: 'my draft' }), + ); + }); + + it('stays inline on desktop', () => { + jest.mocked(useViewSize).mockReturnValue(true); + render(); + + expect(screen.getByText('composer')).toBeInTheDocument(); + expect(mockDrawerProps).not.toHaveBeenCalled(); + expect(mockComposerProps).toHaveBeenCalledWith( + expect.objectContaining({ fills: false }), + ); + }); +}); diff --git a/packages/shared/src/components/comments/CommentInput.tsx b/packages/shared/src/components/comments/CommentInput.tsx new file mode 100644 index 00000000000..ef8689d1599 --- /dev/null +++ b/packages/shared/src/components/comments/CommentInput.tsx @@ -0,0 +1,69 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import type { CommentMarkdownInputProps } from '../fields/MarkdownInput/CommentMarkdownInput'; +import { CommentMarkdownInput } from '../fields/MarkdownInput/CommentMarkdownInput'; +import { WriteCommentContext } from '../../contexts/WriteCommentContext'; +import { useMutateComment } from '../../hooks/post/useMutateComment'; +import { useViewSize, ViewSize } from '../../hooks'; +import { Drawer, DrawerPosition } from '../drawers/Drawer'; + +export interface CommentInputProps extends CommentMarkdownInputProps { + onClose?: () => void; + /** Inline on small viewports too — the companion must not cover the host page. */ + forceInline?: boolean; +} + +export default function CommentInput({ + onClose, + className, + forceInline = false, + ...props +}: CommentInputProps): ReactElement { + const isFullScreen = !useViewSize(ViewSize.Laptop) && !forceInline; + // The draft lives above the drawer/inline swap at the Laptop breakpoint. + const [draft, setDraft] = useState(); + + const mutateCommentResult = useMutateComment({ + post: props.post, + editCommentId: props.editCommentId, + parentCommentId: props.parentCommentId, + onCommented: props.onCommented, + }); + + const composer = ( + { + setDraft(value); + props.onChange?.(value); + }} + onClose={onClose} + /> + ); + + return ( + + {isFullScreen ? ( + onClose?.()} + className={{ wrapper: 'flex flex-col !p-0' }} + > + {composer} + + ) : ( + composer + )} + + ); +} diff --git a/packages/shared/src/components/comments/CommentInputOrModal.tsx b/packages/shared/src/components/comments/CommentInputOrModal.tsx deleted file mode 100644 index d681890db25..00000000000 --- a/packages/shared/src/components/comments/CommentInputOrModal.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import type { CommentMarkdownInputProps } from '../fields/MarkdownInput/CommentMarkdownInput'; -import { CommentMarkdownInput } from '../fields/MarkdownInput/CommentMarkdownInput'; -import { ViewSize, useViewSize } from '../../hooks'; -import type { LazyModalCommonProps } from '../modals/common/Modal'; -import CommentModal from '../modals/post/CommentModal'; -import { WriteCommentContext } from '../../contexts/WriteCommentContext'; -import { useMutateComment } from '../../hooks/post/useMutateComment'; - -interface CommentInputOrModalProps - extends Partial, - Omit { - onClose?: () => void; - className?: { - input?: CommentMarkdownInputProps['className']; - modal?: string; - }; - replyToCommentId?: string; -} - -export default function CommentInputOrModal({ - onClose, - className, - ...props -}: CommentInputOrModalProps): ReactElement { - const isModal = !useViewSize(ViewSize.Tablet); - - const mutateCommentResult = useMutateComment({ - post: props.post, - editCommentId: props.editCommentId, - parentCommentId: props.parentCommentId, - onCommented: props.onCommented, - }); - - if (isModal) { - return ; - } - - return ( - - - - ); -} diff --git a/packages/shared/src/components/comments/MainComment.tsx b/packages/shared/src/components/comments/MainComment.tsx index dfcb01eed49..690f4490137 100644 --- a/packages/shared/src/components/comments/MainComment.tsx +++ b/packages/shared/src/components/comments/MainComment.tsx @@ -27,11 +27,8 @@ import { useEditCommentProps } from '../../hooks/post/useEditCommentProps'; import { useLogContext } from '../../contexts/LogContext'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -const CommentInputOrModal = dynamic( - () => - import( - /* webpackChunkName: "commentInputOrModal" */ './CommentInputOrModal' - ), +const CommentInput = dynamic( + () => import(/* webpackChunkName: "commentInput" */ './CommentInput'), ); type ClassName = { @@ -210,7 +207,7 @@ export default function MainComment({ )} {editProps && ( - { @@ -218,12 +215,12 @@ export default function MainComment({ onCommented?.(...params); }} onClose={() => onEdit(null)} - className={{ input: className?.commentBox }} + className={className?.commentBox} /> )} {commentId === comment.id && ( - { @@ -231,8 +228,7 @@ export default function MainComment({ onCommented?.(...params); }} onClose={() => onReplyTo(null)} - className={{ input: className?.commentBox }} - replyToCommentId={commentId} + className={className?.commentBox} /> )} diff --git a/packages/shared/src/components/comments/SubComment.tsx b/packages/shared/src/components/comments/SubComment.tsx index c6a9161ef91..f9e80da5c59 100644 --- a/packages/shared/src/components/comments/SubComment.tsx +++ b/packages/shared/src/components/comments/SubComment.tsx @@ -9,11 +9,8 @@ import type { CommentMarkdownInputProps } from '../fields/MarkdownInput/CommentM import { useComments } from '../../hooks/post'; import { useEditCommentProps } from '../../hooks/post/useEditCommentProps'; -const CommentInputOrModal = dynamic( - () => - import( - /* webpackChunkName: "commentInputOrModal" */ './CommentInputOrModal' - ), +const CommentInput = dynamic( + () => import(/* webpackChunkName: "commentInput" */ './CommentInput'), ); export interface SubCommentProps @@ -116,7 +113,7 @@ function SubComment({ )} {editProps && ( - { @@ -124,21 +121,20 @@ function SubComment({ onCommented?.(data, isNew); }} onClose={() => onEdit(null)} - className={{ input: className }} + className={className} /> )} {commentId === comment.id && inputProps && ( - { onReplyTo(null); onCommented?.(...params); }} onClose={() => onReplyTo(null)} - replyToCommentId={commentId} /> )} diff --git a/packages/shared/src/components/drawers/Drawer.spec.tsx b/packages/shared/src/components/drawers/Drawer.spec.tsx new file mode 100644 index 00000000000..2aaec32e69e --- /dev/null +++ b/packages/shared/src/components/drawers/Drawer.spec.tsx @@ -0,0 +1,217 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { useVisualViewport } from '../../hooks/utils/useVisualViewport'; +import { Drawer } from './Drawer'; + +jest.mock('../../hooks/utils/useVisualViewport', () => ({ + useVisualViewport: jest.fn(), +})); + +describe('Drawer', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(useVisualViewport) + .mockReturnValue({ width: 375, height: 812, offsetTop: 0 }); + }); + + it('sizes a full-screen drawer to the visual viewport', () => { + jest + .mocked(useVisualViewport) + .mockReturnValue({ width: 375, height: 500, offsetTop: 40 }); + + render( + + content + , + ); + + const overlay = screen.getByText('content').closest('.fixed'); + expect(overlay).toHaveStyle({ height: '500px', top: '40px' }); + }); + + it('leaves non-full-screen drawers sized by CSS', () => { + render( + + content + , + ); + + const overlay = screen.getByText('content').closest('.fixed'); + expect(overlay).not.toHaveStyle({ height: '812px' }); + }); + + it('locks the page scroll behind it while open', () => { + const { unmount } = render( + + content + , + ); + + expect(document.body).toHaveClass('hidden-scrollbar'); + expect(document.documentElement).toHaveStyle({ overflow: 'hidden' }); + + unmount(); + expect(document.body).not.toHaveClass('hidden-scrollbar'); + expect(document.documentElement).not.toHaveStyle({ overflow: 'hidden' }); + }); + + it('keeps the page locked until the last stacked drawer closes', () => { + const { unmount: unmountInner } = render( + + inner + , + ); + const { unmount: unmountOuter } = render( + + outer + , + ); + + unmountInner(); + expect(document.body).toHaveClass('hidden-scrollbar'); + + unmountOuter(); + expect(document.body).not.toHaveClass('hidden-scrollbar'); + }); + + it('leaves the page scrollable behind a partial drawer', () => { + const { unmount } = render( + + content + , + ); + + expect(document.body).not.toHaveClass('hidden-scrollbar'); + expect(document.documentElement).not.toHaveStyle({ overflow: 'hidden' }); + unmount(); + }); + + it('hands back the inline overflow it found instead of deleting it', () => { + // Another lock (react-modal today, something else later) may already own + // an inline overflow; the last drawer out must not wipe it. + document.documentElement.style.overflow = 'clip'; + + const { unmount } = render( + + content + , + ); + expect(document.documentElement).toHaveStyle({ overflow: 'hidden' }); + + unmount(); + expect(document.documentElement).toHaveStyle({ overflow: 'clip' }); + document.documentElement.style.removeProperty('overflow'); + }); + + it('contains its own scrolling instead of chaining it to the page', () => { + render( + + content + , + ); + + const wrapper = screen.getByText('content').closest('.overflow-y-auto'); + expect(wrapper).toHaveClass('overscroll-contain'); + }); + + it('exposes dialog semantics and takes focus on open', () => { + render( + + content + , + ); + + const dialog = screen.getByRole('dialog', { name: 'Filters' }); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveFocus(); + }); + + it('restores focus to the opener when it closes', () => { + const opener = document.createElement('button'); + document.body.appendChild(opener); + opener.focus(); + + const { unmount } = render( + + content + , + ); + expect(opener).not.toHaveFocus(); + + unmount(); + expect(opener).toHaveFocus(); + opener.remove(); + }); + + it('closes the top-most drawer on Escape', () => { + jest.useFakeTimers(); + const onCloseOuter = jest.fn(); + const onCloseInner = jest.fn(); + render( + + outer + , + ); + render( + + inner + , + ); + + fireEvent.keyDown(document, { key: 'Escape' }); + act(() => { + jest.advanceTimersByTime(400); + }); + expect(onCloseInner).toHaveBeenCalledTimes(1); + expect(onCloseOuter).not.toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('keeps Tab cycling inside the drawer', () => { + render( + + first + last + , + ); + + const first = screen.getByRole('button', { name: 'first' }); + const last = screen.getByRole('button', { name: 'last' }); + + last.focus(); + fireEvent.keyDown(last, { key: 'Tab' }); + expect(first).toHaveFocus(); + + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }); + expect(last).toHaveFocus(); + }); + + it('closes only on a direct backdrop hit, not on bubbled child clicks', () => { + jest.useFakeTimers(); + const onClose = jest.fn(); + render( + + child + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'child' })); + // The close call is debounced behind the 300ms exit animation, so give the + // clock a chance to prove nothing was scheduled. + act(() => { + jest.advanceTimersByTime(400); + }); + expect(onClose).not.toHaveBeenCalled(); + + const overlay = screen + .getByRole('button', { name: 'child' }) + .closest('.fixed') as Element; + fireEvent.click(overlay); + act(() => { + jest.advanceTimersByTime(400); + }); + expect(onClose).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); +}); diff --git a/packages/shared/src/components/drawers/Drawer.tsx b/packages/shared/src/components/drawers/Drawer.tsx index 75f51091294..5004115ea6d 100644 --- a/packages/shared/src/components/drawers/Drawer.tsx +++ b/packages/shared/src/components/drawers/Drawer.tsx @@ -1,6 +1,6 @@ import type { + ForwardedRef, HTMLAttributes, - MutableRefObject, ReactElement, ReactNode, } from 'react'; @@ -11,6 +11,7 @@ import ConditionalWrapper from '../ConditionalWrapper'; import { ButtonVariant } from '../buttons/common'; import { Button } from '../buttons/Button'; import { RootPortal } from '../tooltips/Portal'; +import { useVisualViewport } from '../../hooks/utils/useVisualViewport'; export type PopupEventType = | MouseEvent @@ -57,6 +58,16 @@ export interface DrawerOnMobileProps { drawerProps?: Omit; } +// Drawers can stack; the page unlocks only when the last one leaves. +let scrollLockCount = 0; +let previousHtmlOverflow = ''; + +// Escape must close only the top-most drawer of a stack. +const drawerStack: symbol[] = []; + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + const drawerPositionToClassName: Record = { [DrawerPosition.Bottom]: 'bottom-0 rounded-t-16', [DrawerPosition.Top]: 'top-0 rounded-b-16', @@ -86,7 +97,18 @@ function BaseDrawer({ instantOpen = false, ...props }: DrawerProps): ReactElement { - const container = useRef(); + const container = useRef(null); + const stackToken = useRef(); + if (!stackToken.current) { + stackToken.current = Symbol('drawer'); + } + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const { height: viewportHeight, offsetTop } = useVisualViewport(isFullScreen); + const keyboardSafeStyle = + isFullScreen && viewportHeight + ? { height: viewportHeight, top: offsetTop } + : undefined; const [hasAnimated, setHasAnimated] = useState(instantOpen); const [animate] = useDebounceFn(() => setHasAnimated(true), 1); const classes = className?.drawer ?? 'px-4 py-3'; @@ -99,15 +121,90 @@ function BaseDrawer({ }; }, [onAfterClose, onAfterOpen]); + useEffect(() => { + const token = stackToken.current; + const previouslyFocused = document.activeElement as HTMLElement | null; + drawerStack.push(token); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape' || drawerStack[drawerStack.length - 1] !== token) { + return; + } + e.stopPropagation(); + onCloseRef.current(e); + }; + document.addEventListener('keydown', onKeyDown); + + if (!container.current?.contains(document.activeElement)) { + container.current?.focus(); + } + + return () => { + drawerStack.splice(drawerStack.indexOf(token), 1); + document.removeEventListener('keydown', onKeyDown); + if (previouslyFocused?.isConnected) { + previouslyFocused.focus(); + } + }; + }, []); + + useEffect(() => { + if (!isFullScreen) { + return undefined; + } + + // is the page's actual scroller — body-level `overflow: hidden` + // alone never reaches the viewport. + if (scrollLockCount === 0) { + previousHtmlOverflow = document.documentElement.style.overflow; + } + scrollLockCount += 1; + document.body.classList.add('hidden-scrollbar'); + document.documentElement.style.overflow = 'hidden'; + + return () => { + scrollLockCount -= 1; + if (scrollLockCount > 0) { + return; + } + document.body.classList.remove('hidden-scrollbar'); + if (previousHtmlOverflow) { + document.documentElement.style.overflow = previousHtmlOverflow; + } else { + document.documentElement.style.removeProperty('overflow'); + } + }; + }, [isFullScreen]); + + const trapFocus = (e: React.KeyboardEvent) => { + if (e.key !== 'Tab' || !container.current) { + return; + } + const focusable = Array.from( + container.current.querySelectorAll(FOCUSABLE_SELECTOR), + ); + if (!focusable.length) { + e.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + if (e.shiftKey && (active === first || active === container.current)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + }; + const handleOverlayClick = (e: React.MouseEvent) => { e.stopPropagation(); - if ( - closeOnOutsideClick && - hasAnimated && - container.current && - !container.current.contains(e.target as Node) - ) { + // Not a `contains` check: portaled children (dropdowns, popovers) live + // under document.body, yet React bubbles their clicks to this handler. + if (closeOnOutsideClick && hasAnimated && e.target === e.currentTarget) { onClose(e.nativeEvent); } }; @@ -116,17 +213,30 @@ function BaseDrawer({ // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} , + ref: ForwardedRef, ): ReactElement | null { const [isClosing, setIsClosing] = useState(false); - const [debounceClosing] = useDebounceFn((e: PopupEventType) => { + const [debounceClosing] = useDebounceFn((e) => { setIsClosing(false); - onClose?.(e); + // `onClosing`, the only caller, always forwards the event. + onClose?.(e as PopupEventType); }, ANIMATION_MS); - const onClosing = () => { + const onClosing = (e?: PopupEventType) => { setIsClosing(true); - debounceClosing(); + debounceClosing(e); }; useImperativeHandle(ref, () => ({ onClose: onClosing })); diff --git a/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.spec.tsx b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.spec.tsx new file mode 100644 index 00000000000..0cd7ab50718 --- /dev/null +++ b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.spec.tsx @@ -0,0 +1,274 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { Post } from '../../../graphql/posts'; +import { WriteCommentContext } from '../../../contexts/WriteCommentContext'; +import { CommentMarkdownInput } from './CommentMarkdownInput'; + +const mockRichTextProps = jest.fn(); + +const mockNotificationToggle = { + shouldShowCta: false, + isEnabled: true, + onToggle: jest.fn(), + onSubmitted: jest.fn().mockResolvedValue(undefined), +}; + +// The real hook needs auth/push contexts that are irrelevant to this suite. +jest.mock('../../../hooks/notifications', () => ({ + useNotificationToggle: () => mockNotificationToggle, +})); + +jest.mock('../RichTextInput', () => { + const react = jest.requireActual('react') as typeof React; + + return { + __esModule: true, + default: react.forwardRef((props: Record) => { + mockRichTextProps(props); + return ; + }), + }; +}); + +const post = { + id: 'post-1', + author: { username: 'ido' }, + source: { id: 'source-1', handle: 'webdev' }, +} as Post; + +const renderComposer = ( + props: Partial> = {}, + mutateComment: jest.Mock = jest.fn(), +) => + render( + + + , + ); + +const setViewportHeight = (height: number) => { + const viewport = { + height, + width: 375, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: viewport, + }); + return viewport; +}; + +describe('CommentMarkdownInput', () => { + beforeEach(() => { + mockRichTextProps.mockClear(); + mockNotificationToggle.onSubmitted.mockClear(); + mockNotificationToggle.shouldShowCta = false; + }); + + it('offers the notification opt-in above the full-screen action bar', () => { + mockNotificationToggle.shouldShowCta = true; + renderComposer({ fills: true }); + + const props = mockRichTextProps.mock.calls.at(-1)[0]; + expect(props.stackToolbarLeading).toBe(true); + expect(props.toolbarLeading).toBeTruthy(); + }); + + it('hides the notification opt-in on the inline composer', () => { + mockNotificationToggle.shouldShowCta = true; + renderComposer({ fills: false }); + + const props = mockRichTextProps.mock.calls.at(-1)[0]; + expect(props.toolbarLeading).toBeUndefined(); + }); + + it('primes the notification prompt only after a successful submit', async () => { + mockNotificationToggle.shouldShowCta = true; + renderComposer({ fills: true }, jest.fn().mockResolvedValue({ id: 'c1' })); + + fireEvent.submit(screen.getByRole('form')); + + await waitFor(() => + expect(mockNotificationToggle.onSubmitted).toHaveBeenCalled(), + ); + }); + + it('does not prime the notification prompt when the submit fails', async () => { + mockNotificationToggle.shouldShowCta = true; + const mutateComment = jest.fn().mockResolvedValue(undefined); + renderComposer({ fills: true }, mutateComment); + + fireEvent.submit(screen.getByRole('form')); + + await waitFor(() => expect(mutateComment).toHaveBeenCalled()); + expect(mockNotificationToggle.onSubmitted).not.toHaveBeenCalled(); + }); + + it('caps its height to the visible viewport so the keyboard cannot hide it', () => { + setViewportHeight(360); + renderComposer(); + + expect(screen.getByRole('form')).toHaveStyle({ maxHeight: '288px' }); + }); + + it('keeps a workable floor when the visible viewport is tiny', () => { + setViewportHeight(120); + renderComposer(); + + expect(screen.getByRole('form')).toHaveStyle({ maxHeight: '224px' }); + }); + + it('does not grow past its cap on a tall desktop viewport', () => { + setViewportHeight(1200); + renderComposer(); + + expect(screen.getByRole('form')).toHaveStyle({ maxHeight: '512px' }); + }); + + it('does not subscribe to viewport churn when it fills its container', () => { + const viewport = setViewportHeight(800); + renderComposer({ fills: true }); + + expect(viewport.addEventListener).not.toHaveBeenCalled(); + }); + + it('subscribes to the viewport when capped against it', () => { + const viewport = setViewportHeight(800); + renderComposer(); + + expect(viewport.addEventListener).toHaveBeenCalled(); + }); + + it('moves the actions into a pinned bottom bar instead of the footer', () => { + setViewportHeight(800); + renderComposer(); + + expect(mockRichTextProps).toHaveBeenCalledWith( + expect.objectContaining({ toolbarPosition: 'bottom', hideFooter: true }), + ); + }); + + // The header is handed to RichTextInput as a node, so it is rendered on its + // own here. Tooltip reaches for the query client, hence the provider. + const renderHeader = () => { + const [{ header }] = mockRichTextProps.mock.calls.at(-1); + + return render( + + {header} + , + ); + }; + + it('names the comment author being replied to', () => { + setViewportHeight(800); + renderComposer({ parentCommentId: 'comment-1', replyTo: 'AmirMushich' }); + renderHeader(); + + expect(screen.getByText(/Replying to/)).toBeInTheDocument(); + expect(screen.getByText('@AmirMushich')).toBeInTheDocument(); + }); + + it('falls back to the post author on a top-level comment', () => { + setViewportHeight(800); + renderComposer(); + renderHeader(); + + expect(screen.getByText('@ido')).toBeInTheDocument(); + }); + + it('falls back to the source when the post has no author', () => { + setViewportHeight(800); + renderComposer({ post: { ...post, author: undefined } as Post }); + renderHeader(); + + expect(screen.getByText('@webdev')).toBeInTheDocument(); + }); + + it('says it is an edit rather than naming someone to reply to', () => { + setViewportHeight(800); + renderComposer({ editCommentId: 'comment-1' }); + renderHeader(); + + expect(screen.getByText('Editing your comment')).toBeInTheDocument(); + expect(screen.queryByText(/Replying to/)).not.toBeInTheDocument(); + }); + + it('puts the cancel action in the header, not the toolbar', () => { + setViewportHeight(800); + renderComposer({ onClose: jest.fn() }); + + const [{ toolbarRightActions }] = mockRichTextProps.mock.calls.at(-1); + expect(toolbarRightActions).toBeUndefined(); + + renderHeader(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('keeps the markdown toggle in the header rather than the toolbar', () => { + setViewportHeight(800); + renderComposer(); + + expect(mockRichTextProps).toHaveBeenCalledWith( + expect.objectContaining({ hideMarkdownToggle: true }), + ); + + renderHeader(); + expect( + screen.getByRole('button', { name: 'Switch to Markdown' }), + ).toBeInTheDocument(); + }); + + it.each([ + [{}, 'Comment'], + [{ parentCommentId: 'comment-1' }, 'Reply'], + [{ editCommentId: 'comment-1' }, 'Update'], + ])('labels the submit action for the context %o', (props, expected) => { + setViewportHeight(800); + renderComposer(props); + + expect(mockRichTextProps).toHaveBeenCalledWith( + expect.objectContaining({ submitCopy: expected }), + ); + }); + + it('fills its container in the drawer instead of capping against the viewport', () => { + setViewportHeight(360); + renderComposer({ fills: true }); + + const form = screen.getByRole('form'); + expect(form).not.toHaveStyle({ maxHeight: '288px' }); + expect(form).toHaveClass('flex-1'); + const [{ className }] = mockRichTextProps.mock.calls.at(-1); + expect(className.container).not.toContain('border'); + }); + + it('sits on one 20px frame on every side in the drawer', () => { + setViewportHeight(800); + renderComposer({ fills: true }); + + const [{ header, className }] = mockRichTextProps.mock.calls.at(-1); + expect(header.props.className).toContain('px-5 pt-5'); + expect(className.profile).toContain('!ml-5'); + }); + + it('keeps the comment list guideline when rendered inline', () => { + setViewportHeight(800); + renderComposer(); + + const [{ header, className }] = mockRichTextProps.mock.calls.at(-1); + expect(header.props.className).toContain('px-4 pt-2'); + expect(className.profile).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx index e0dd5547fcd..de09c13e150 100644 --- a/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx +++ b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx @@ -1,11 +1,11 @@ import type { - CSSProperties, ForwardedRef, FormEventHandler, FormHTMLAttributes, ReactElement, + ReactNode, } from 'react'; -import React, { forwardRef, useRef } from 'react'; +import React, { forwardRef, useRef, useState } from 'react'; import classNames from 'classnames'; import { defaultMarkdownCommands } from '../../../hooks/input'; import type { RichTextInputRef } from '../RichTextInput'; @@ -14,13 +14,23 @@ import type { Comment } from '../../../graphql/comments'; import { formToJson } from '../../../lib/form'; import type { Post } from '../../../graphql/posts'; import { useWriteCommentContext } from '../../../contexts/WriteCommentContext'; -import { ButtonVariant } from '../../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import CloseButton from '../../CloseButton'; +import { MarkdownIcon } from '../../icons'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { useVisualViewport } from '../../../hooks/utils/useVisualViewport'; +import { useNotificationToggle } from '../../../hooks/notifications'; +import { NotificationPromptSource } from '../../../lib/log'; +import { Switch } from '../Switch'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../typography/Typography'; export interface CommentClassName { container?: string; - tab?: string; - markdownContainer?: string; - input?: string; } export interface CommentMarkdownInputProps { @@ -31,20 +41,23 @@ export interface CommentMarkdownInputProps { initialContent?: string; replyTo?: string; className?: CommentClassName; - style?: CSSProperties; onCommented?: ( comment: Comment, isNew: boolean, parentCommentId?: string, ) => void; - showSubmit?: boolean; - showUserAvatar?: boolean; autoFocus?: boolean; onChange?: (value: string) => void; formProps?: FormHTMLAttributes; onClose?: () => void; + /** Fills its container instead of capping against the viewport. */ + fills?: boolean; } +const MIN_COMPOSER_HEIGHT = 224; +const MAX_COMPOSER_HEIGHT = 512; +const VIEWPORT_HEIGHT_RATIO = 0.8; + export function CommentMarkdownInputComponent( { post, @@ -54,13 +67,11 @@ export function CommentMarkdownInputComponent( editCommentId, parentCommentId, className = {}, - style, onChange, - showSubmit = true, - showUserAvatar = true, autoFocus = true, formProps = {}, onClose, + fills = false, }: CommentMarkdownInputProps, ref: ForwardedRef, ): ReactElement { @@ -71,11 +82,46 @@ export function CommentMarkdownInputComponent( mutateComment: { mutateComment, isLoading, isSuccess }, } = useWriteCommentContext(); const richTextRef = useRef(null); - let submitCopy: string | undefined; - if (showSubmit) { - submitCopy = editCommentId ? 'Update' : 'Comment'; + const [isMarkdownMode, setIsMarkdownMode] = useState(false); + + const { shouldShowCta, isEnabled, onToggle, onSubmitted } = + useNotificationToggle({ source: NotificationPromptSource.NewComment }); + + const { height: viewportHeight } = useVisualViewport(!fills); + const maxHeight = + viewportHeight && !fills + ? Math.min( + MAX_COMPOSER_HEIGHT, + Math.max( + MIN_COMPOSER_HEIGHT, + Math.round(viewportHeight * VIEWPORT_HEIGHT_RATIO), + ), + ) + : undefined; + + let submitCopy = 'Comment'; + if (editCommentId) { + submitCopy = 'Update'; + } else if (parentCommentId) { + submitCopy = 'Reply'; } + const replyingTo = replyTo ?? post?.author?.username ?? post?.source?.handle; + let headerLabel: ReactNode = null; + if (editCommentId) { + headerLabel = 'Editing your comment'; + } else if (replyingTo) { + headerLabel = ( + <> + Replying to @{replyingTo} + > + ); + } + + const markdownToggleLabel = isMarkdownMode + ? 'Switch to rich text' + : 'Switch to Markdown'; + const onSubmitForm: FormEventHandler = async (e) => { e.preventDefault(); @@ -87,9 +133,9 @@ export function CommentMarkdownInputComponent( const result = await mutateComment(content); - // Clear draft after successful submission - if (result && richTextRef.current) { - richTextRef.current.clearDraft(); + if (result) { + richTextRef.current?.clearDraft(); + await onSubmitted(); } return result; @@ -104,9 +150,9 @@ export function CommentMarkdownInputComponent( const result = await mutateComment(content); - // Clear draft after successful submission - if (result && richTextRef.current) { - richTextRef.current.clearDraft(); + if (result) { + richTextRef.current?.clearDraft(); + await onSubmitted(); } return result; @@ -117,8 +163,13 @@ export function CommentMarkdownInputComponent( {...formProps} action="#" onSubmit={onSubmitForm} - className={className?.container} - style={style} + aria-label={submitCopy} + className={classNames( + 'flex min-h-0 flex-col', + fills && 'flex-1', + className?.container, + )} + style={{ maxHeight }} ref={ref} > - Reply to - - {replyTo} - + toolbarPosition="bottom" + hideMarkdownHeader + hideFooter + hideMarkdownToggle + stackToolbarLeading + toolbarLeading={ + fills && shouldShowCta ? ( + + Receive updates when other members engage + + ) : undefined + } + onMarkdownModeChange={setIsMarkdownMode} + header={ + + {headerLabel && ( + + {headerLabel} + + )} + + + } + pressed={isMarkdownMode} + onClick={() => richTextRef.current?.toggleMarkdownMode()} + aria-label={markdownToggleLabel} + aria-pressed={isMarkdownMode} + /> + + {onClose && ( + + )} - ) : null + } onValueUpdate={onChange} - onClose={onClose} /> ); diff --git a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.spec.tsx b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.spec.tsx new file mode 100644 index 00000000000..23d6b706f55 --- /dev/null +++ b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.spec.tsx @@ -0,0 +1,118 @@ +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; +import type { Editor } from '@tiptap/react'; +import { RichTextToolbar } from './RichTextToolbar'; + +const mockTriggerProps = jest.fn(); + +// Radix menu content does not render in jsdom; mocked always-open. +jest.mock('../../dropdown/DropdownMenu', () => ({ + DropdownMenu: ({ children }: React.PropsWithChildren) => ( + {children} + ), + DropdownMenuTrigger: ({ + children, + ...props + }: React.PropsWithChildren>) => { + mockTriggerProps(props); + return children; + }, + DropdownMenuContent: ({ children }: React.PropsWithChildren) => ( + {children} + ), + DropdownMenuItem: ({ children }: React.PropsWithChildren) => ( + {children} + ), +})); + +jest.mock('@tiptap/react', () => ({ + useEditorState: () => ({ + isBold: false, + isItalic: false, + isBulletList: false, + isOrderedList: false, + isLink: false, + canUndo: false, + canRedo: false, + }), +})); + +jest.mock('../../tooltip/Tooltip', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => children, +})); + +jest.mock('./LinkModal', () => ({ + LinkModal: () => null, +})); + +const renderToolbar = (stackLeading: boolean) => + render( + Free form} + rightActions={Post} + />, + ); + +describe('RichTextToolbar leading actions', () => { + it('renders the leading slot exactly once inside the bar by default', () => { + renderToolbar(false); + + expect( + screen.getByRole('button', { name: 'Free form' }), + ).toBeInTheDocument(); + }); + + it('renders the leading slot exactly once on its own row when stacked', () => { + renderToolbar(true); + + expect( + screen.getByRole('button', { name: 'Free form' }), + ).toBeInTheDocument(); + }); + + it('keeps the stacked leading slot out of the clipped bar row', () => { + renderToolbar(true); + + const leading = screen.getByRole('button', { name: 'Free form' }); + // The clipped group is the `overflow-hidden` row; a stacked picker must + // not live inside it, or narrow screens slice it again. + expect(leading.closest('.overflow-hidden')).toBeNull(); + }); + + it('moves overflowed formatting into the menu with a working trigger', () => { + const nativeResizeObserver = global.ResizeObserver; + let triggerResize: (() => void) | undefined; + const observerStub = ( + cb: (entries: { contentRect: { width: number } }[]) => void, + ) => ({ + observe: () => { + triggerResize = () => cb([{ contentRect: { width: 140 } }]); + }, + disconnect: jest.fn(), + unobserve: jest.fn(), + }); + global.ResizeObserver = jest + .fn() + .mockImplementation(observerStub) as unknown as typeof ResizeObserver; + + try { + renderToolbar(false); + act(() => triggerResize?.()); + + expect(screen.getByLabelText('More formatting')).toBeInTheDocument(); + expect(mockTriggerProps).toHaveBeenCalledWith( + expect.objectContaining({ + tooltip: expect.objectContaining({ content: 'More formatting' }), + }), + ); + const menu = screen.getByRole('menu'); + expect(menu).toHaveTextContent('Italic'); + } finally { + global.ResizeObserver = nativeResizeObserver; + } + }); +}); diff --git a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx index f84fb14c74f..bea4f3f8774 100644 --- a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx +++ b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx @@ -15,10 +15,10 @@ import type { Editor } from '@tiptap/react'; import { getMarkRange } from '@tiptap/core'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { - ArrowIcon, BoldIcon, BulletListIcon, ItalicIcon, + MenuIcon, NumberedListIcon, RedoIcon, UndoIcon, @@ -43,6 +43,8 @@ export interface RichTextToolbarProps { position?: 'top' | 'bottom'; className?: string; hideInlineLink?: boolean; + hideFormatting?: boolean; + stackLeading?: boolean; } export interface RichTextToolbarRef { @@ -64,6 +66,7 @@ interface ToolbarItem { const ToolbarDivider = (): ReactElement => ( ); @@ -123,19 +126,20 @@ const OverflowMenu = ({ items }: OverflowMenuProps): ReactElement | null => { return ( - - - } - aria-label="More formatting" - onMouseDown={(event: React.MouseEvent) => event.preventDefault()} - className="shrink-0" - /> - - + {/* The trigger's own `tooltip` prop, not a `Tooltip` wrapper: the wrapper + blurs its trigger on mouseup, which insta-dismisses non-modal Radix + menus before they ever paint. */} + + } + aria-label="More formatting" + onMouseDown={(event: React.MouseEvent) => event.preventDefault()} + className="shrink-0" + /> + {items.map((item) => ( { }; const TOOLBAR_BUTTON_WIDTH = 32; +// jsdom fallbacks only: the live budget measures the rendered divider and the +// row's own column-gap, so a class change cannot desync the overflow maths. +const FALLBACK_DIVIDER_WIDTH = 13; +const FALLBACK_ROW_GAP = 4; + +const measureDividerWidth = (root: HTMLElement | null): number => { + const divider = root?.querySelector('[data-toolbar-divider]'); + if (!divider) { + return FALLBACK_DIVIDER_WIDTH; + } + const style = getComputedStyle(divider); + const width = + divider.getBoundingClientRect().width + + (parseFloat(style.marginLeft) || 0) + + (parseFloat(style.marginRight) || 0); + return width > 0 ? width : FALLBACK_DIVIDER_WIDTH; +}; + +const measureRowGap = (root: HTMLElement | null): number => { + if (!root) { + return FALLBACK_ROW_GAP; + } + const gap = parseFloat(getComputedStyle(root).columnGap); + return Number.isFinite(gap) && gap > 0 ? gap : FALLBACK_ROW_GAP; +}; function RichTextToolbarComponent( { @@ -170,6 +199,8 @@ function RichTextToolbarComponent( position = 'top', className, hideInlineLink = false, + hideFormatting = false, + stackLeading = false, }: RichTextToolbarProps, ref: Ref, ): ReactElement { @@ -234,6 +265,10 @@ function RichTextToolbarComponent( }); const formattingItems = useMemo(() => { + if (hideFormatting) { + return []; + } + const items: ToolbarItem[] = []; if (!hideInlineLink) { @@ -328,6 +363,7 @@ function RichTextToolbarComponent( editor, editorState, hideInlineLink, + hideFormatting, openLinkModal, ]); @@ -370,15 +406,28 @@ function RichTextToolbarComponent( inlineActionsRef.current?.getBoundingClientRect().width ?? 0; const rightWidth = rightActionsRef.current?.getBoundingClientRect().width ?? 0; + // Dividers and the row's own gaps are laid out but belong to no ref, so + // the budget must account for them separately. + const dividerCount = + (leadingActions && !stackLeading ? 1 : 0) + + (inlineActions ? 1 : 0) + + Math.max(0, new Set(formattingItems.map((item) => item.group)).size - 1); setReservedWidth( - leadingWidth + inlineWidth + rightWidth + TOOLBAR_BUTTON_WIDTH, + leadingWidth + + inlineWidth + + rightWidth + + TOOLBAR_BUTTON_WIDTH + + dividerCount * measureDividerWidth(containerRef.current) + + measureRowGap(containerRef.current) * 2, ); }, [ isOverflowable, rightActions, inlineActions, leadingActions, + stackLeading, containerWidth, + formattingItems, ]); const visibleCount = useMemo(() => { @@ -438,6 +487,11 @@ function RichTextToolbarComponent( return ( <> + {stackLeading && leadingActions && ( + + {leadingActions} + + )} - {leadingActions && ( - <> - - {leadingActions} - - - > - )} - {inlineActions && ( - <> - - {inlineActions} - - {visibleItems.length > 0 && } - > - )} - {renderedFormattingItems} + + {!stackLeading && leadingActions && ( + <> + + {leadingActions} + + + > + )} + {inlineActions && ( + <> + + {inlineActions} + + {visibleItems.length > 0 && } + > + )} + {renderedFormattingItems} + + {/* Outside the clipped group so the overflow menu is never cut itself. */} {isOverflowable && } {rightActions && ( diff --git a/packages/shared/src/components/fields/RichTextEditor/richtext.module.css b/packages/shared/src/components/fields/RichTextEditor/richtext.module.css index 2072e9a88fd..2252c597896 100644 --- a/packages/shared/src/components/fields/RichTextEditor/richtext.module.css +++ b/packages/shared/src/components/fields/RichTextEditor/richtext.module.css @@ -86,7 +86,9 @@ /* Tiptap ProseMirror specific */ & :global(.ProseMirror) { - @apply outline-none min-h-[6rem]; + /* flex-1, not a min-height (it would stack on `minHeightClassName`) nor + `height: 100%` (it resolves to nothing under a min-height-only ancestor). */ + @apply outline-none flex-1; } & :global(.ProseMirror-focused) { diff --git a/packages/shared/src/components/fields/RichTextInput.spec.tsx b/packages/shared/src/components/fields/RichTextInput.spec.tsx index 684877519e8..45e59687e1f 100644 --- a/packages/shared/src/components/fields/RichTextInput.spec.tsx +++ b/packages/shared/src/components/fields/RichTextInput.spec.tsx @@ -1,5 +1,8 @@ -import { render } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; +import type { LoggedUser } from '../../lib/user'; +import type { RichTextInputRef } from './RichTextInput'; import RichTextInput from './RichTextInput'; const mockFocus = jest.fn(); @@ -9,7 +12,15 @@ const mockEditor = { focus: mockFocus, setContent: jest.fn(), }, + getHTML: jest.fn(() => ''), }; +let mockEditorReady = true; +let mockUser: Partial | null = null; + +const renderWithClient = (ui: React.ReactElement) => + render( + {ui}, + ); jest.mock('next/dynamic', () => () => () => null); @@ -50,8 +61,17 @@ jest.mock('@tiptap/react', () => ({ __esModule: true, useEditor: (options: unknown) => { mockUseEditor(options); - return mockEditor; + return mockEditorReady ? mockEditor : null; }, + useEditorState: () => ({ + isBold: false, + isItalic: false, + isBulletList: false, + isOrderedList: false, + isLink: false, + canUndo: false, + canRedo: false, + }), EditorContent: () => { const react = jest.requireActual('react') as typeof React; return react.createElement('div', { 'data-testid': 'editor-content' }); @@ -59,7 +79,28 @@ jest.mock('@tiptap/react', () => ({ })); jest.mock('../../contexts/AuthContext', () => ({ - useAuthContext: () => ({ user: null }), + useAuthContext: () => ({ user: mockUser }), +})); + +jest.mock('../tooltip/Tooltip', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => children, +})); + +// The tooltip content doubles as an aria-label so icon-only buttons stay reachable. +jest.mock('../tooltips/SimpleTooltip', () => ({ + SimpleTooltip: ({ + content, + children, + }: React.PropsWithChildren<{ content: React.ReactNode }>) => { + const react = jest.requireActual('react') as typeof React; + return react.cloneElement(children as React.ReactElement, { + 'aria-label': String(content), + }); + }, +})); + +jest.mock('./RichTextEditor/LinkModal', () => ({ + LinkModal: () => null, })); jest.mock('../../hooks/usePopupSelector', () => ({ @@ -120,7 +161,9 @@ jest.mock('./RichTextEditor/useEmojiAutocomplete', () => ({ describe('RichTextInput', () => { beforeEach(() => { - mockUseEditor.mockClear(); + jest.clearAllMocks(); + mockEditorReady = true; + mockUser = null; }); it('exposes the input id on the rich editor DOM attributes', () => { @@ -149,4 +192,75 @@ describe('RichTextInput', () => { }), ); }); + + it('keeps one avatar mounted across the markdown toggle', () => { + mockUser = { + id: 'u1', + username: 'ido', + image: 'https://daily.dev/ido.png', + }; + renderWithClient( + , + ); + + const avatar = screen.getByAltText("ido's profile"); + + fireEvent.click( + screen.getByLabelText('Switch to Markdown Editor', { + selector: 'button', + }), + ); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByAltText("ido's profile")).toBe(avatar); + + fireEvent.click( + screen.getByLabelText('Switch to Rich Text Editor', { + selector: 'button', + }), + ); + expect(screen.getByTestId('editor-content')).toBeInTheDocument(); + expect(screen.getByAltText("ido's profile")).toBe(avatar); + }); + + it('queues an early focus until the editor is created', () => { + // The editor is created async (`immediatelyRender: false`), while the + // composer requests autofocus from a mount-time ref callback. + mockEditorReady = false; + const ref = React.createRef(); + const { rerender } = render( + , + ); + + ref.current?.focus(); + expect(mockFocus).not.toHaveBeenCalled(); + + mockEditorReady = true; + rerender(); + + expect(mockFocus).toHaveBeenCalledWith('end'); + }); + + it('gives the bottom bar the safe-area floor instead of the drawer', () => { + render(); + + expect( + document.querySelector('[class*="safe-area-inset-bottom"]'), + ).toBeInTheDocument(); + }); + + it('swaps only the editor element between modes', () => { + render(); + + expect(screen.getByTestId('editor-content')).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByLabelText('Switch to Markdown Editor', { + selector: 'button', + }), + ); + + expect(screen.queryByTestId('editor-content')).not.toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); }); diff --git a/packages/shared/src/components/fields/RichTextInput.tsx b/packages/shared/src/components/fields/RichTextInput.tsx index ce066115802..5ca419ce3a2 100644 --- a/packages/shared/src/components/fields/RichTextInput.tsx +++ b/packages/shared/src/components/fields/RichTextInput.tsx @@ -133,7 +133,7 @@ interface RichTextInputProps { submitButtonVariant?: ButtonVariant; showUserAvatar?: boolean; isUpdatingDraft?: boolean; - timeline?: ReactNode; + header?: ReactNode; isLoading?: boolean; disabledSubmit?: boolean; maxInputLength?: number; @@ -154,6 +154,7 @@ interface RichTextInputProps { toolbarPosition?: 'top' | 'bottom'; toolbarLeading?: ReactNode; toolbarRightActions?: ReactNode; + stackToolbarLeading?: boolean; hideMarkdownToggle?: boolean; hideMarkdownHeader?: boolean; hideFooter?: boolean; @@ -178,7 +179,7 @@ function RichTextInput( submitButtonVariant = ButtonVariant.Float, showUserAvatar, isUpdatingDraft, - timeline, + header, isLoading, disabledSubmit, maxInputLength, @@ -199,6 +200,7 @@ function RichTextInput( toolbarPosition = 'top', toolbarLeading, toolbarRightActions, + stackToolbarLeading = false, hideMarkdownToggle = false, hideMarkdownHeader = false, hideFooter = false, @@ -214,6 +216,10 @@ function RichTextInput( const editorContainerRef = useRef(null); const editorRef = useRef(null); const markdownTextareaRef = useRef(null); + const scrollContainerRef = useRef(null); + const scrollOffsetRef = useRef(0); + const shouldRestoreScrollRef = useRef(false); + const pendingFocusRef = useRef(false); const dirtyRef = useRef(false); const isSyncingRef = useRef(false); const inputRef = useRef(''); @@ -523,21 +529,28 @@ function RichTextInput( upload.insertImage(gifUrl, altText); }; + const rememberScroll = useCallback(() => { + scrollOffsetRef.current = scrollContainerRef.current?.scrollTop ?? 0; + shouldRestoreScrollRef.current = true; + }, []); + const switchToMarkdownMode = useCallback(() => { + rememberScroll(); if (editorRef.current) { const markdown = htmlToMarkdownBasic(editorRef.current.getHTML()); updateInput(markdown); } setIsMarkdownMode(true); - }, [updateInput]); + }, [rememberScroll, updateInput]); const switchToRichMode = useCallback(() => { + rememberScroll(); if (editorRef.current) { isSyncingRef.current = true; editorRef.current.commands.setContent(markdownToHtml(inputRef.current)); } setIsMarkdownMode(false); - }, [markdownToHtml]); + }, [markdownToHtml, rememberScroll]); const toggleMarkdownMode = useCallback(() => { if (isMarkdownMode) { @@ -552,6 +565,14 @@ function RichTextInput( }, [isMarkdownMode, onMarkdownModeChange]); const didInitMarkdownRef = useRef(false); + const restoreScroll = useCallback(() => { + if (!shouldRestoreScrollRef.current || !scrollContainerRef.current) { + return; + } + scrollContainerRef.current.scrollTop = scrollOffsetRef.current; + shouldRestoreScrollRef.current = false; + }, []); + useEffect(() => { if (!didInitMarkdownRef.current) { didInitMarkdownRef.current = true; @@ -559,13 +580,22 @@ function RichTextInput( } const frame = requestAnimationFrame(() => { if (isMarkdownMode) { - markdownTextareaRef.current?.focus(); + const textarea = markdownTextareaRef.current; + if (!textarea) { + return; + } + textarea.focus({ preventScroll: true }); + const end = textarea.value.length; + textarea.setSelectionRange(end, end); + restoreScroll(); return; } - editorRef.current?.commands.focus(); + // `scrollIntoView: false` or ProseMirror undoes the restore below. + editorRef.current?.commands.focus('end', { scrollIntoView: false }); + restoreScroll(); }); return () => cancelAnimationFrame(frame); - }, [isMarkdownMode]); + }, [isMarkdownMode, restoreScroll]); useLayoutEffect(() => { if (!isMarkdownMode) { @@ -575,13 +605,13 @@ function RichTextInput( if (!ta) { return; } - if (toolbarPosition === 'bottom') { - ta.style.height = ''; - return; - } + // Measured from `auto` so `rows` (and `min-height`) stay the floor. ta.style.height = 'auto'; ta.style.height = `${ta.scrollHeight}px`; - }, [input, isMarkdownMode, toolbarPosition]); + // Swapping editors momentarily shrinks the scroll container, clamping its + // offset to 0; restore before paint — a later frame is too late. + restoreScroll(); + }, [input, isMarkdownMode, restoreScroll]); const onMarkdownInput = useCallback( (event: React.FormEvent) => { @@ -680,7 +710,14 @@ function RichTextInput( return; } - editor?.commands.focus('end'); + if (!editor) { + // The editor is created async (`immediatelyRender: false`), so a focus + // requested at mount — the composer's autofocus — would silently miss. + pendingFocusRef.current = true; + return; + } + + editor.commands.focus('end'); }, toggleMarkdownMode, })); @@ -700,6 +737,16 @@ function RichTextInput( } }, [editor, initialContent, input, markdownToHtml, updateInput]); + // Ordered after the initial-content sync above so a queued autofocus lands + // with the caret at the end of the prefilled mention, not an empty doc. + useEffect(() => { + if (!editor || !pendingFocusRef.current) { + return; + } + pendingFocusRef.current = false; + editor.commands.focus('end'); + }, [editor]); + const actionIcon = upload.queueCount === 0 ? ( @@ -718,6 +765,32 @@ function RichTextInput( : editor?.storage.characterCount?.characters?.() ?? input.length) : null; + const isBottomToolbar = toolbarPosition === 'bottom'; + // Rendered outside the rich/markdown branches so switching editors never + // drops it. + const avatar = showUserAvatar && user && ( + + ); + const renderSubmitButton = (buttonClassName?: string) => + shouldShowSubmit ? ( + + {submitCopy} + + ) : null; + const hasToolbarActions = isUploadEnabled || isLinkEnabled || isMentionEnabled || isGifEnabled; const preventEditorBlur = (event: React.MouseEvent) => event.preventDefault(); @@ -812,6 +885,119 @@ function RichTextInput( /> ) : null; + // Both editors hang off one tree so toggling markdown swaps only the editor + // element instead of remounting the avatar and action bar. + const rightActionsNode = ( + + {savingLabel} + {!hideMarkdownToggle && ( + + : } + onClick={isMarkdownMode ? switchToRichMode : switchToMarkdownMode} + /> + + )} + {onClose && } + {toolbarRightActions} + {isBottomToolbar && renderSubmitButton()} + + ); + + const toolbarNode = hideToolbar ? null : ( + { + if (!editor) { + return; + } + if (!editor.state.selection.empty) { + editor.chain().focus().setLink({ href: url }).run(); + return; + } + const linkText = label || url; + editor + .chain() + .focus() + .insertContent({ + type: 'text', + text: linkText, + marks: [{ type: 'link', attrs: { href: url } }], + }) + .run(); + }} + position={toolbarPosition} + className={ + // The bar absorbs the device safe area itself; the drawer around it + // adds no bottom padding of its own (`!p-0`). + isBottomToolbar + ? '!gap-3 !px-5 !pb-[max(1.25rem,env(safe-area-inset-bottom))] !pt-4' + : undefined + } + leadingActions={toolbarLeading} + stackLeading={stackToolbarLeading} + inlineActions={ + hasToolbarActions && !isMarkdownMode ? toolbarActions : null + } + hideInlineLink={isLinkEnabled} + hideFormatting={isMarkdownMode} + rightActions={rightActionsNode} + /> + ); + + const editorBody = ( + + {avatar} + {isMarkdownMode ? ( + + ) : ( + + )} + + ); + return ( - ( - - - {timeline} - {component} - - )} + {header} + event.preventDefault() + } + onPaste={isMarkdownMode ? undefined : upload.handlePaste} > - event.preventDefault() - } - onPaste={isMarkdownMode ? undefined : upload.handlePaste} - > - {isMarkdownMode ? ( - <> - {!hideMarkdownHeader && ( - - - Markdown editor - - - {savingLabel} - - } - onClick={switchToRichMode} - /> - - {onClose && ( - - )} - - - )} - ( - - {component} - - )} - > - - - > - ) : ( - (() => { - const inlineActionsNode = hasToolbarActions - ? toolbarActions - : null; - const rightActionsNode = ( - - {savingLabel} - {!hideMarkdownToggle && ( - - } - onClick={switchToMarkdownMode} - /> - - )} - {onClose && ( - - )} - {toolbarRightActions} - - ); - const toolbarNode = hideToolbar ? null : ( - { - if (!editor) { - return; - } - if (!editor.state.selection.empty) { - editor.chain().focus().setLink({ href: url }).run(); - return; - } - const linkText = label || url; - editor - .chain() - .focus() - .insertContent({ - type: 'text', - text: linkText, - marks: [{ type: 'link', attrs: { href: url } }], - }) - .run(); - }} - position={toolbarPosition} - className={ - toolbarPosition === 'bottom' - ? '!gap-3 !px-5 !pb-5 !pt-4' - : undefined - } - leadingActions={toolbarLeading} - inlineActions={inlineActionsNode} - hideInlineLink={isLinkEnabled} - rightActions={rightActionsNode} + {isMarkdownMode && !hideMarkdownHeader && !isBottomToolbar && ( + + + Markdown editor + + + {savingLabel} + + } + onClick={switchToRichMode} /> - ); - const editorBody = ( - - {showUserAvatar && user && ( - - )} - - - ); - return ( - <> - {toolbarPosition === 'top' && toolbarNode} - {isUploadEnabled && ( - - )} - {toolbarPosition === 'bottom' ? ( - - {editorBody} - - ) : ( - editorBody - )} - {toolbarPosition === 'bottom' && toolbarNode} - > - ); - })() - )} - {textareaProps.name && ( - + + {onClose && ( + + )} + + + )} + {toolbarPosition === 'top' && !isMarkdownMode && toolbarNode} + {isUploadEnabled && ( + + )} + ( + + {component} + )} - - + > + {editorBody} + + {isBottomToolbar && toolbarNode} + {textareaProps.name && ( + + )} + {!isMarkdownMode && ( } @@ -1054,20 +1118,10 @@ function RichTextInput( {remainingCharacters} )} - {shouldShowSubmit && ( - - {submitCopy} - - )} + {!isBottomToolbar && + renderSubmitButton( + maxLength && remainingCharacters !== null ? '' : 'ml-auto', + )} )} diff --git a/packages/shared/src/components/fields/Switch.spec.tsx b/packages/shared/src/components/fields/Switch.spec.tsx new file mode 100644 index 00000000000..c8b5d60b135 --- /dev/null +++ b/packages/shared/src/components/fields/Switch.spec.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { Switch } from './Switch'; + +const LONG_LABEL = + 'Receive updates whenever your comment gets a reply or an upvote'; + +const renderSwitch = (onToggle = jest.fn()): void => { + render( + + {LONG_LABEL} + , + ); +}; + +describe('Switch', () => { + it('toggles when the label is clicked', () => { + const onToggle = jest.fn(); + renderSwitch(onToggle); + + fireEvent.click(screen.getByLabelText(LONG_LABEL)); + + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it('keeps the track full size next to a long label', () => { + renderSwitch(); + + const track = screen + .getByLabelText(LONG_LABEL) + .parentElement?.querySelector('.touch-none'); + expect(track).toHaveClass('shrink-0'); + }); + + it('lets a long label wrap instead of running off screen', () => { + renderSwitch(); + + expect(screen.getByText(LONG_LABEL)).toHaveClass('min-w-0'); + }); +}); diff --git a/packages/shared/src/components/fields/Switch.tsx b/packages/shared/src/components/fields/Switch.tsx index ee77f2fc0b4..5dfa6619d93 100644 --- a/packages/shared/src/components/fields/Switch.tsx +++ b/packages/shared/src/components/fields/Switch.tsx @@ -181,7 +181,7 @@ function SwitchComponent( /> { - if (!data?.postComments?.edges) { - return undefined; - } - - // eslint-disable-next-line no-restricted-syntax - for (const item of data?.postComments?.edges) { - if (item.node.id === commentId) { - return item.node; - } - if (item.node.children) { - // eslint-disable-next-line no-restricted-syntax - for (const child of item.node.children.edges) { - if (child.node.id === commentId) { - return child.node; - } - } - } - } - - return undefined; -}; - -interface GetCommentFromCacheProps { - client: QueryClient; - postId: string; - commentId?: string; -} - -const getCommentFromCache = ({ - client, - postId, - commentId, -}: GetCommentFromCacheProps): Comment | undefined => { - if (!commentId) { - return undefined; - } - - const keys = getAllCommentsQuery(postId); - // eslint-disable-next-line no-restricted-syntax - for (const key of keys) { - const comment = getComment( - client.getQueryData(key), - commentId, - ); - - if (comment) { - return comment; - } - } - - return undefined; -}; - -export interface CommentModalProps - extends LazyModalCommonProps, - CommentMarkdownInputProps { - replyToCommentId?: string; -} - -export default function CommentModal({ - isOpen, - onRequestClose, - onAfterClose, - onCommented, - parentCommentId, - replyToCommentId, - editCommentId, - post, - initialContent: initialContentFromProps, -}: CommentModalProps): ReactElement { - const inputRef = useRef(null); - const headerRef = useRef(null); - const replyRef = useRef(null); - const switchRef = useRef(null); - - const { user } = useAuthContext(); - const client = useQueryClient(); - const [modalNode, setModalNode] = useState(null); - - const isEdit = !!editCommentId; - const isReply = !isEdit && !!replyToCommentId; - - const { comment: commentById } = useCommentById({ - id: editCommentId, - options: { enabled: !!editCommentId }, - }); - - const refCallback = useCallback( - (node: HTMLElement | null) => { - if (node) { - setModalNode(node); - } - }, - [setModalNode], - ); - - const { shouldShowCta, isEnabled, onToggle, onSubmitted } = - useNotificationToggle({ - source: NotificationPromptSource.NewComment, - }); - - const onSuccess: typeof onCommented = (comment, isNew, parentCommentID) => { - if (onCommented) { - onCommented(comment, isNew, parentCommentID); - } - - onRequestClose(); - }; - - const comment = useMemo( - () => - getCommentFromCache({ - client, - postId: post?.id, - commentId: isEdit ? editCommentId : replyToCommentId ?? parentCommentId, - }), - [ - client, - post?.id, - isEdit, - editCommentId, - parentCommentId, - replyToCommentId, - ], - ); - - const mutateCommentResult = useMutateComment({ - post, - editCommentId: isEdit && editCommentId, - parentCommentId, - onCommented: onSuccess, - }); - const { isLoading, isSuccess } = mutateCommentResult; - - useLayoutEffect(() => { - // scroll to bottom of modal - modalNode?.scrollTo?.({ behavior: 'auto', top: 10000 }); - }, [modalNode]); - - const { height } = useVisualViewport(); - const replyHeight = replyRef.current?.clientHeight ?? 0; - const footerHeight = switchRef.current?.clientHeight ?? 0; - const headerHeight = headerRef.current?.offsetHeight ?? 0; - const totalHeight = height - headerHeight - replyHeight - footerHeight; - const inputHeight = totalHeight > 0 ? Math.max(totalHeight, 300) : 'auto'; - - if ( - inputRef.current && - inputRef.current.style?.height !== `${inputHeight}px` - ) { - inputRef.current.style.height = `${inputHeight}px`; - } - - const { submitCopy, initialContent } = useMemo(() => { - if (isEdit) { - return { - submitCopy: 'Save', - initialContent: commentById?.content, - }; - } - if (isReply) { - return { - submitCopy: 'Reply', - initialContent: - comment?.author?.id && comment.author.id !== user?.id - ? `@${comment?.author?.username} ` - : undefined, - }; - } - return { - submitCopy: 'Comment', - initialContent: initialContentFromProps, - }; - }, [ - isEdit, - isReply, - comment, - commentById, - user?.id, - initialContentFromProps, - ]); - - return ( - - - - { - await onSubmitted(); - }, - loading: isLoading, - disabled: isSuccess, - }} - className={{ - container: 'flex-1 first:!border-none', - header: 'sticky top-0 z-2 w-full bg-background-default', - }} - headerRef={headerRef} - > - {isReply && comment && ( - <> - - - Reply to - - {comment.author?.username} - - - > - )} - - {shouldShowCta && ( - - Receive updates when other members engage - - )} - - - - - ); -} diff --git a/packages/shared/src/components/modals/post/SmartComposerModal.spec.tsx b/packages/shared/src/components/modals/post/SmartComposerModal.spec.tsx index d766ddea3ca..39fa4e2722c 100644 --- a/packages/shared/src/components/modals/post/SmartComposerModal.spec.tsx +++ b/packages/shared/src/components/modals/post/SmartComposerModal.spec.tsx @@ -233,4 +233,56 @@ describe('SmartComposerModal', () => { expect.objectContaining({ event_name: LogEvent.SubmitSmartComposer }), ); }); + + it('hides the expand control on mobile, where it is already full-screen', () => { + jest.mocked(useViewSize).mockReturnValue(false); + + renderWithClient( + , + ); + + expect( + screen.queryByRole('button', { name: 'Expand composer' }), + ).not.toBeInTheDocument(); + }); + + it('keeps the expand control on desktop', () => { + renderWithClient( + , + ); + + expect( + screen.getByRole('button', { name: 'Expand composer' }), + ).toBeInTheDocument(); + }); + + it('moves the schedule action next to the header scheduling control on mobile', () => { + jest.mocked(useViewSize).mockReturnValue(false); + + renderWithClient( + , + ); + + const schedule = screen.getByRole('button', { name: 'Schedule post' }); + const scheduledNav = screen.getByRole('button', { + name: 'Scheduled posts', + }); + expect(schedule.parentElement).toBe(scheduledNav.parentElement); + }); + + it('keeps the schedule action beside the Post button on desktop', () => { + renderWithClient( + , + ); + + const schedule = screen.getByRole('button', { name: 'Schedule post' }); + const scheduledNav = screen.getByRole('button', { + name: 'Scheduled posts', + }); + expect(schedule.parentElement).not.toBe(scheduledNav.parentElement); + }); }); diff --git a/packages/shared/src/components/modals/post/SmartComposerModal.tsx b/packages/shared/src/components/modals/post/SmartComposerModal.tsx index 1945b5d407b..115425c7e92 100644 --- a/packages/shared/src/components/modals/post/SmartComposerModal.tsx +++ b/packages/shared/src/components/modals/post/SmartComposerModal.tsx @@ -450,11 +450,10 @@ export function SmartComposerModal({ {submitLabel} ); - // Self-contained flex with its own gap so the button pair keeps identical - // spacing regardless of the parent (rich-text toolbar vs. bottom action bar). + const scheduleInHeader = !isLaptop; const primaryActionsNode = ( - {scheduleButtonNode} + {!scheduleInHeader && scheduleButtonNode} {postButtonNode} ); @@ -506,6 +505,7 @@ export function SmartComposerModal({ onClick={handleViewScheduled} disabled={isInFlight} /> + {scheduleInHeader && scheduleButtonNode} {kind === 'text' && ( )} - - - ) : ( - - ) - } - onClick={onToggleExpand} - aria-label={isExpanded ? 'Collapse composer' : 'Expand composer'} - aria-pressed={isExpanded} - /> - + {isLaptop && ( + + + ) : ( + + ) + } + onClick={onToggleExpand} + aria-label={ + isExpanded ? 'Collapse composer' : 'Expand composer' + } + aria-pressed={isExpanded} + /> + + )} - {!isMarkdownMode && notificationToggleNode && ( - + {notificationToggleNode && ( + {notificationToggleNode} )} @@ -614,7 +619,7 @@ export function SmartComposerModal({ {kind === 'poll' && } )} - {((kind !== 'text' && kind !== 'standup') || isMarkdownMode) && ( + {kind !== 'text' && kind !== 'standup' && ( {kindPickerNode} @@ -638,7 +643,7 @@ export function SmartComposerModal({ handleClose(); }} onAfterClose={props.onAfterClose} - className={{ wrapper: 'flex flex-col p-0' }} + className={{ wrapper: 'flex flex-col !p-0' }} > {formContent} diff --git a/packages/shared/src/components/post/NewComment.spec.tsx b/packages/shared/src/components/post/NewComment.spec.tsx index 86dc395b421..fae960f3739 100644 --- a/packages/shared/src/components/post/NewComment.spec.tsx +++ b/packages/shared/src/components/post/NewComment.spec.tsx @@ -44,13 +44,13 @@ jest.mock('../image/Image', () => ({ Image: () => null, })); -const CommentInputOrModal = ({ - inputId, -}: { - inputId: string; -}): React.ReactElement => ( - -); +const mockCommentInputProps = jest.fn(); + +const CommentInput = (props: { inputId: string }): React.ReactElement => { + mockCommentInputProps(props); + const { inputId } = props; + return ; +}; describe('NewComment', () => { const originalRequestAnimationFrame = window.requestAnimationFrame; @@ -76,7 +76,7 @@ describe('NewComment', () => { render( , ); @@ -88,4 +88,21 @@ describe('NewComment', () => { expect(screen.getByTestId('comment-input')).toHaveFocus(); }); }); + + it('lets the composer own its focus, since its editor mounts async', () => { + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: /share your thoughts/i }), + ); + + expect(mockCommentInputProps).toHaveBeenCalledWith( + expect.objectContaining({ autoFocus: true }), + ); + }); }); diff --git a/packages/shared/src/components/post/NewComment.tsx b/packages/shared/src/components/post/NewComment.tsx index 705f41c3580..7c0ad1aa51d 100644 --- a/packages/shared/src/components/post/NewComment.tsx +++ b/packages/shared/src/components/post/NewComment.tsx @@ -35,7 +35,7 @@ export interface NewCommentTriggerRenderProps { interface NewCommentProps extends CommentMarkdownInputProps { size?: ProfileImageSize; shouldHandleCommentQuery?: boolean; - CommentInputOrModal: React.ElementType; + CommentInput: React.ElementType; onComposerOpenChange?: (isOpen: boolean) => void; renderTrigger?: (props: NewCommentTriggerRenderProps) => ReactElement; } @@ -53,6 +53,7 @@ const focusInputById = (inputId: string, remainingFrames = 30): void => { const input = document.getElementById(inputId); if (input) { input.focus(); + input.scrollIntoView?.({ behavior: 'smooth', block: 'center' }); return; } @@ -74,7 +75,7 @@ function NewCommentComponent( onCommented, post, shouldHandleCommentQuery = false, - CommentInputOrModal, + CommentInput, onComposerOpenChange, renderTrigger, ...props @@ -154,12 +155,14 @@ function NewCommentComponent( if (isComposerOpen) { return ( - setInputContent(undefined)} diff --git a/packages/shared/src/components/post/PostComments.tsx b/packages/shared/src/components/post/PostComments.tsx index 719044a9230..fa0a54a64d3 100644 --- a/packages/shared/src/components/post/PostComments.tsx +++ b/packages/shared/src/components/post/PostComments.tsx @@ -32,7 +32,7 @@ interface PostCommentsProps { isComposerOpen?: boolean; permissionNotificationCommentId?: string; joinNotificationCommentId?: string; - modalParentSelector?: () => HTMLElement; + modalParentSelector?: () => HTMLElement | null; onShare?: (comment: Comment) => void; onClickUpvote?: (commentId: string, upvotes: number) => unknown; className?: CommentClassName; diff --git a/packages/shared/src/components/post/PostEngagements.tsx b/packages/shared/src/components/post/PostEngagements.tsx index 3aa08de19ad..bcecebc7f00 100644 --- a/packages/shared/src/components/post/PostEngagements.tsx +++ b/packages/shared/src/components/post/PostEngagements.tsx @@ -32,17 +32,16 @@ import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import SocialBar from '../cards/socials/SocialBar'; import { PostContentReminder } from './common/PostContentReminder'; import { useSettingsContext } from '../../contexts/SettingsContext'; +import { useOpenPostCommentRequest } from '../../hooks/post/useOpenPostCommentRequest'; import { usePostComments } from '../../hooks/comments/usePostComments'; const AuthorOnboarding = dynamic( () => import(/* webpackChunkName: "authorOnboarding" */ './AuthorOnboarding'), ); -const CommentInputOrModal = dynamic( +const CommentInput = dynamic( () => - import( - /* webpackChunkName: "commentInputOrModal" */ '../comments/CommentInputOrModal' - ), + import(/* webpackChunkName: "commentInput" */ '../comments/CommentInput'), ); interface PostEngagementsProps { @@ -76,7 +75,7 @@ function PostEngagements({ const { commentsCount } = usePostComments({ postId: post.id, sortBy }); const { user, showLogin } = useAuthContext(); const { isPlus } = usePlusSubscription(); - const commentRef = useRef(); + const commentRef = useRef(null); const [authorOnboarding, setAuthorOnboarding] = useState(false); const [permissionNotificationCommentId, setPermissionNotificationCommentId] = useState(); @@ -122,6 +121,8 @@ function PostEngagements({ } }, [shouldOnboardAuthor]); + useOpenPostCommentRequest(commentRef); + return ( <> } @@ -170,13 +173,13 @@ function PostEngagements({ )} {!isPlus && !hideInternalAd && } showLogin({ trigger: AuthTriggers.Author })) + user ? undefined : () => showLogin({ trigger: AuthTriggers.Author }) } /> )} diff --git a/packages/shared/src/components/post/composer/AudienceChip.tsx b/packages/shared/src/components/post/composer/AudienceChip.tsx index 521bcc91629..6f41ebb506e 100644 --- a/packages/shared/src/components/post/composer/AudienceChip.tsx +++ b/packages/shared/src/components/post/composer/AudienceChip.tsx @@ -158,7 +158,8 @@ export const AudienceChip = ({ aria-expanded={canPickAudience ? open : undefined} aria-label={buildAriaLabel()} className={classNames( - 'flex max-w-full shrink-0 items-center gap-1.5 rounded-12 px-2.5 py-1 text-text-primary transition-colors typo-callout', + // `shrink` opts back in past the global `flex-shrink: 0` reset. + 'flex min-w-0 max-w-full shrink items-center gap-1.5 rounded-12 px-2.5 py-1 text-text-primary transition-colors typo-callout', showChevron && 'hover:bg-surface-float', !showChevron && 'cursor-default', open && showChevron && 'bg-surface-float', @@ -171,7 +172,7 @@ export const AudienceChip = ({ ) )} - + {triggerLabel} {showChevron && ( diff --git a/packages/shared/src/components/post/composer/PollForm.tsx b/packages/shared/src/components/post/composer/PollForm.tsx index 099c26f1562..89a12246080 100644 --- a/packages/shared/src/components/post/composer/PollForm.tsx +++ b/packages/shared/src/components/post/composer/PollForm.tsx @@ -109,7 +109,7 @@ export const PollForm = ({ value, onChange }: PollFormProps): ReactElement => { placeholder={`Option ${index + 1}`} onChange={(e) => updateOption(index, e.currentTarget.value)} aria-label={`Poll option ${index + 1}`} - className="flex-1 bg-transparent text-text-primary outline-none typo-callout placeholder:text-text-quaternary" + className="min-w-0 flex-1 bg-transparent text-text-primary outline-none typo-callout placeholder:text-text-quaternary" /> {POLL_OPTION_MAX_LENGTH - option.length} diff --git a/packages/shared/src/components/post/composer/TextForm.tsx b/packages/shared/src/components/post/composer/TextForm.tsx index 2019d3e603b..c6d4dfd84b8 100644 --- a/packages/shared/src/components/post/composer/TextForm.tsx +++ b/packages/shared/src/components/post/composer/TextForm.tsx @@ -38,6 +38,7 @@ interface TextFormProps { onCoverChange?: (cover: TextFormCover | null) => void; toolbarLeading?: ReactNode; toolbarRightActions?: ReactNode; + stackToolbarLeading?: boolean; onMarkdownModeChange?: (isMarkdownMode: boolean) => void; } @@ -58,6 +59,7 @@ export const TextForm = forwardRef( onCoverChange, toolbarLeading, toolbarRightActions, + stackToolbarLeading, onMarkdownModeChange, }, ref, @@ -219,6 +221,7 @@ export const TextForm = forwardRef( toolbarPosition="bottom" toolbarLeading={toolbarLeading} toolbarRightActions={toolbarRightActions} + stackToolbarLeading={stackToolbarLeading} hideMarkdownToggle hideMarkdownHeader hideFooter diff --git a/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx index b9327545c7a..3da40bb639c 100644 --- a/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx +++ b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx @@ -3,6 +3,7 @@ import type { LegacyRef, ReactElement } from 'react'; import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; +import { useOpenPostCommentRequest } from '../../../hooks/post/useOpenPostCommentRequest'; import { useShareComment } from '../../../hooks/useShareComment'; import { useUpvoteQuery } from '../../../hooks/useUpvoteQuery'; import { Origin } from '../../../lib/log'; @@ -28,10 +29,10 @@ import { usePostComments } from '../../../hooks/comments/usePostComments'; import { DiscussionMetaBar } from './DiscussionMetaBar'; import { DiscussionShareRow } from './DiscussionShareRow'; -const CommentInputOrModal = dynamic( +const CommentInput = dynamic( () => import( - /* webpackChunkName: "commentInputOrModal" */ '../../comments/CommentInputOrModal' + /* webpackChunkName: "commentInput" */ '../../comments/CommentInput' ), ); @@ -86,6 +87,8 @@ export const PostDiscussionPanel = ({ const { onShowUpvoted } = useUpvoteQuery(); const { openShareComment } = useShareComment(origin); + useOpenPostCommentRequest(commentRef); + useEffect(() => { if (!onRegisterFocusComment) { return undefined; @@ -165,7 +168,7 @@ export const PostDiscussionPanel = ({ shouldHandleCommentQuery onComposerOpenChange={setIsComposerOpen} size={ProfileImageSize.Medium} - CommentInputOrModal={CommentInputOrModal} + CommentInput={CommentInput} renderTrigger={renderComposerTrigger} /> diff --git a/packages/shared/src/components/post/reader/EngagementRail.tsx b/packages/shared/src/components/post/reader/EngagementRail.tsx index da08a2649b3..b52093b552a 100644 --- a/packages/shared/src/components/post/reader/EngagementRail.tsx +++ b/packages/shared/src/components/post/reader/EngagementRail.tsx @@ -54,10 +54,10 @@ const SquadEntityCard = dynamic( }, ); -const CommentInputOrModal = dynamic( +const CommentInput = dynamic( () => import( - /* webpackChunkName: "commentInputOrModal" */ '../../comments/CommentInputOrModal' + /* webpackChunkName: "commentInput" */ '../../comments/CommentInput' ), ); @@ -317,7 +317,7 @@ export function EngagementRail({ shouldHandleCommentQuery onComposerOpenChange={setIsComposerOpen} size={ProfileImageSize.Medium} - CommentInputOrModal={CommentInputOrModal} + CommentInput={CommentInput} /> void; + export type PostReferrerContext = { activePost?: Post; + requestOpenComment?: OpenCommentHandler; + onOpenCommentRequest?: (handler: OpenCommentHandler) => () => void; }; const [ActivePostContextProvider, useActivePostContextHook] = createContextProvider( ({ post }: PostReferrerContextProps): PostReferrerContext => { + const openCommentHandlers = useRef(new Set()); + return useMemo(() => { return { activePost: post, + requestOpenComment: (origin) => + openCommentHandlers.current.forEach((handler) => handler(origin)), + onOpenCommentRequest: (handler) => { + openCommentHandlers.current.add(handler); + return () => { + openCommentHandlers.current.delete(handler); + }; + }, }; }, [post]); }, diff --git a/packages/shared/src/graphql/squads.ts b/packages/shared/src/graphql/squads.ts index 0aecfa0b73d..d28d823ce24 100644 --- a/packages/shared/src/graphql/squads.ts +++ b/packages/shared/src/graphql/squads.ts @@ -784,7 +784,7 @@ export const isPrivilegedRole = ( ].includes(role); }; -export const isSourcePublicSquad = (source: Source): boolean => +export const isSourcePublicSquad = (source?: Source): boolean => !!(source?.type === SourceType.Squad && source?.public); export const SQUAD_COMMENT_JOIN_BANNER_KEY = generateStorageKey( diff --git a/packages/shared/src/hooks/post/useOpenPostCommentRequest.spec.tsx b/packages/shared/src/hooks/post/useOpenPostCommentRequest.spec.tsx new file mode 100644 index 00000000000..353d0160d4d --- /dev/null +++ b/packages/shared/src/hooks/post/useOpenPostCommentRequest.spec.tsx @@ -0,0 +1,74 @@ +import { render } from '@testing-library/react'; +import type { MutableRefObject } from 'react'; +import React from 'react'; +import type { NewCommentRef } from '../../components/post/NewComment'; +import type { Post } from '../../graphql/posts'; +import { Origin } from '../../lib/log'; +import { + ActivePostContextProvider, + useActivePostContext, +} from '../../contexts/ActivePostContext'; +import { useOpenPostCommentRequest } from './useOpenPostCommentRequest'; + +const post = { id: 'p1' } as Post; + +let requestOpenComment: (origin: Origin) => void; + +const Requester = (): null => { + const context = useActivePostContext(); + requestOpenComment = (origin) => context.requestOpenComment?.(origin); + return null; +}; + +const Listener = ({ + commentRef, +}: { + commentRef: MutableRefObject; +}): null => { + useOpenPostCommentRequest(commentRef); + return null; +}; + +describe('useOpenPostCommentRequest', () => { + it('opens the composer when the provider receives a request', () => { + const onShowInput = jest.fn(); + const commentRef = { current: { onShowInput } }; + render( + + + + , + ); + + requestOpenComment(Origin.PostCommentButton); + expect(onShowInput).toHaveBeenCalledWith(Origin.PostCommentButton); + }); + + it('stops listening after unmount', () => { + const onShowInput = jest.fn(); + const commentRef = { current: { onShowInput } }; + const { rerender } = render( + + + + , + ); + + rerender( + + + , + ); + requestOpenComment(Origin.PostCommentButton); + + expect(onShowInput).not.toHaveBeenCalled(); + }); + + it('is inert without a provider', () => { + const onShowInput = jest.fn(); + const commentRef = { current: { onShowInput } }; + + expect(() => render()).not.toThrow(); + expect(onShowInput).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/hooks/post/useOpenPostCommentRequest.ts b/packages/shared/src/hooks/post/useOpenPostCommentRequest.ts new file mode 100644 index 00000000000..c4bc77a7a49 --- /dev/null +++ b/packages/shared/src/hooks/post/useOpenPostCommentRequest.ts @@ -0,0 +1,19 @@ +import type { RefObject } from 'react'; +import { useEffect } from 'react'; +import type { NewCommentRef } from '../../components/post/NewComment'; +import { useActivePostContext } from '../../contexts/ActivePostContext'; + +// Opens this post's composer when the layout's floating bar asks for it. +export const useOpenPostCommentRequest = ( + commentRef: RefObject, +): void => { + const { onOpenCommentRequest } = useActivePostContext(); + + useEffect( + () => + onOpenCommentRequest?.((origin) => + commentRef.current?.onShowInput(origin), + ), + [onOpenCommentRequest, commentRef], + ); +}; diff --git a/packages/shared/src/hooks/utils/useVisualViewport.spec.tsx b/packages/shared/src/hooks/utils/useVisualViewport.spec.tsx new file mode 100644 index 00000000000..8f8676e96a2 --- /dev/null +++ b/packages/shared/src/hooks/utils/useVisualViewport.spec.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { useVisualViewport } from './useVisualViewport'; + +const Viewport = ({ enabled }: { enabled?: boolean }): React.ReactElement => { + const { width, height, offsetTop } = useVisualViewport(enabled); + + return {`${width}x${height}@${offsetTop}`}; +}; + +type ViewportStub = EventTarget & { + width: number; + height: number; + offsetTop: number; +}; + +describe('useVisualViewport', () => { + let viewport: ViewportStub; + + beforeEach(() => { + viewport = Object.assign(new EventTarget(), { + width: 375, + height: 812, + offsetTop: 0, + }); + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: viewport, + }); + }); + + afterEach(() => { + Reflect.deleteProperty(window, 'visualViewport'); + }); + + it('reads size and offset from the visual viewport', () => { + render(); + + expect(screen.getByText('375x812@0')).toBeInTheDocument(); + }); + + it('tracks the keyboard shrinking the visual viewport', () => { + render(); + + viewport.height = 500; + act(() => { + viewport.dispatchEvent(new Event('resize')); + }); + + expect(screen.getByText('375x500@0')).toBeInTheDocument(); + }); + + it('does not subscribe when disabled', () => { + render(); + + viewport.height = 500; + act(() => { + viewport.dispatchEvent(new Event('resize')); + }); + + expect(screen.getByText('375x812@0')).toBeInTheDocument(); + }); + + it('tracks iOS panning the layout viewport under the keyboard', () => { + render(); + + viewport.offsetTop = 40; + act(() => { + viewport.dispatchEvent(new Event('scroll')); + }); + + expect(screen.getByText('375x812@40')).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/hooks/utils/useVisualViewport.ts b/packages/shared/src/hooks/utils/useVisualViewport.ts index 0bf5cbab42c..e3eb143b748 100644 --- a/packages/shared/src/hooks/utils/useVisualViewport.ts +++ b/packages/shared/src/hooks/utils/useVisualViewport.ts @@ -4,18 +4,28 @@ import { useEventListener } from '../useEventListener'; interface VisualViewportResult { width?: number; height?: number; + offsetTop?: number; } const getVisualViewport = (): VisualViewportResult => ({ width: globalThis?.window?.visualViewport?.width ?? 0, height: globalThis?.window?.visualViewport?.height ?? 0, + // iOS scrolls the layout viewport under the keyboard rather than resizing it, + // so a fixed overlay has to be pushed down by this much to stay on screen. + offsetTop: globalThis?.window?.visualViewport?.offsetTop ?? 0, }); -export const useVisualViewport = (): VisualViewportResult => { +/** + * @param enabled subscribe to viewport changes. Pass `false` from consumers + * that only read the value in some states: `scroll` fires continuously on iOS + * while the keyboard is open, and each event re-renders the whole subtree. + */ +export const useVisualViewport = (enabled = true): VisualViewportResult => { const [viewPort, setViewPort] = useState(getVisualViewport); // <- only calls the function 1 time this way - performance improvement - useEventListener(globalThis?.window?.visualViewport, 'resize', () => - setViewPort(getVisualViewport), - ); + const update = () => setViewPort(getVisualViewport); + const target = enabled ? globalThis?.window?.visualViewport : undefined; + useEventListener(target, 'resize', update); + useEventListener(target, 'scroll', update); return viewPort; }; diff --git a/packages/storybook/stories/components/comments/CommentComposer.stories.tsx b/packages/storybook/stories/components/comments/CommentComposer.stories.tsx new file mode 100644 index 00000000000..a39f1d9a170 --- /dev/null +++ b/packages/storybook/stories/components/comments/CommentComposer.stories.tsx @@ -0,0 +1,132 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { CommentMarkdownInput } from '@dailydotdev/shared/src/components/fields/MarkdownInput/CommentMarkdownInput'; +import { + ComposerHarness, + longComment, + markdownComment, + overflowingWord, + post, + postWithoutAuthor, + shortComment, +} from './composer.mocks'; + +const meta: Meta = { + title: 'Components/Comments/Composer', + component: CommentMarkdownInput, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'The inline comment composer. Same component on every viewport — no modal on mobile. It grows with its content, caps against the *visual* viewport (the part that survives the virtual keyboard), then scrolls its own body so the bottom action bar stays reachable.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + args: { post, onClose: () => undefined }, +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + name: 'Empty — submit disabled', + args: { initialContent: '' }, +}; + +export const SourceFallback: Story = { + name: 'No post author — falls back to the source', + args: { post: postWithoutAuthor, initialContent: shortComment }, +}; + +export const MarkdownMode: Story = { + name: 'Markdown mode — toggle from the header', + args: { initialContent: markdownComment }, + play: async ({ canvasElement }) => { + const toggle = canvasElement.querySelector( + 'button[aria-label="Switch to Markdown"]', + ); + toggle?.click(); + }, +}; + +export const Typing: Story = { + name: 'Short comment — fits without scrolling', + args: { initialContent: shortComment }, +}; + +export const LongComment: Story = { + name: 'Long comment — capped and scrolling', + args: { initialContent: longComment }, +}; + +export const UnbreakableWord: Story = { + name: 'Unbreakable word — wraps, never scrolls sideways', + args: { initialContent: overflowingWord }, +}; + +export const RichContent: Story = { + name: 'Rich content — headings, lists, code', + args: { initialContent: markdownComment }, +}; + +export const Reply: Story = { + name: 'Reply — "Replying to" strip', + args: { + parentCommentId: 'comment-1', + replyTo: 'AmirMushich', + initialContent: shortComment, + }, +}; + +export const ReplyLong: Story = { + name: 'Reply — strip stays pinned above a scrolling body', + args: { + parentCommentId: 'comment-1', + replyTo: 'AmirMushich', + initialContent: longComment, + }, +}; + +export const Edit: Story = { + name: 'Edit — header names the action, submits as "Update"', + args: { editCommentId: 'comment-1', initialContent: shortComment }, +}; + +export const WithoutClose: Story = { + name: 'No close handler — header keeps the markdown toggle', + args: { initialContent: shortComment, onClose: undefined }, +}; + + +export const Submitting: Story = { + name: 'Submitting — button in flight', + args: { initialContent: shortComment }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Submitted: Story = { + name: 'Submitted — locked until unmount', + args: { initialContent: shortComment }, + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/packages/storybook/stories/components/comments/CommentComposerStates.stories.tsx b/packages/storybook/stories/components/comments/CommentComposerStates.stories.tsx new file mode 100644 index 00000000000..1ce7c3a1fd8 --- /dev/null +++ b/packages/storybook/stories/components/comments/CommentComposerStates.stories.tsx @@ -0,0 +1,180 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import type { ReactNode } from 'react'; +import { CommentMarkdownInput } from '@dailydotdev/shared/src/components/fields/MarkdownInput/CommentMarkdownInput'; +import { + ComposerHarness, + longComment, + markdownComment, + post, + shortComment, + WriteComment, +} from './composer.mocks'; + +interface CaseProps { + title: string; + note: string; + width?: string; + children: ReactNode; +} + +const Case = ({ title, note, width = '100%', children }: CaseProps) => ( + + + {title} + {note} + + {children} + +); + +const Gallery = ({ children }: { children: ReactNode }) => ( + {children} +); + +const meta: Meta = { + title: 'Components/Comments/Composer states', + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'Every state the comment composer can be in, side by side. Use the mobile story to check the keyboard-safe cap: the composer never grows past 80% of the visual viewport, so the action bar stays above the keyboard.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const AllStates: Story = { + name: 'All states', + render: () => ( + + + + undefined} /> + + + undefined} + /> + + + undefined} + /> + + + undefined} + /> + + + undefined} + /> + + + undefined} + /> + + + + + + + undefined} + /> + + + + + ), +}; + +export const MobileWidths: Story = { + name: 'Mobile widths', + parameters: { viewport: { defaultViewport: 'mobile1' } }, + render: () => ( + + + + undefined} + /> + + + undefined} + /> + + + + ), +}; diff --git a/packages/storybook/stories/components/comments/composer.mocks.tsx b/packages/storybook/stories/components/comments/composer.mocks.tsx new file mode 100644 index 00000000000..66339a6d7d0 --- /dev/null +++ b/packages/storybook/stories/components/comments/composer.mocks.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import type { FC, PropsWithChildren } from 'react'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { WriteCommentContext } from '@dailydotdev/shared/src/contexts/WriteCommentContext'; +import ExtensionProviders from '../../extension/_providers'; + +export const post = { + id: 'post-1', + title: 'Example Post Title', + author: { id: 'author-1', username: 'ido' }, + source: { id: 'source-1', handle: 'webdev', type: SourceType.Squad }, +} as unknown as Post; + +export const postWithoutAuthor = { + ...post, + author: undefined, +} as unknown as Post; + +interface MutationState { + isLoading?: boolean; + isSuccess?: boolean; +} + +export const WriteComment: FC> = ({ + children, + isLoading = false, + isSuccess = false, +}) => ( + null, + isLoading, + isSuccess, + } as never, + }} + > + {children} + +); + +export const ComposerHarness: FC< + PropsWithChildren +> = ({ children, className = 'max-w-[40rem]', ...mutation }) => ( + + + {/* react-modal is configured against `#__next`, which Storybook has no + equivalent of; the toolbar's link modal crashes without it. */} + + {children} + + + +); + +const paragraph = + 'the composer grows with what you type, then stops at its cap and scrolls its own body so the action bar never moves.'; + +export const shortComment = `This one fits without scrolling.`; + +export const longComment = Array.from( + { length: 14 }, + (_, index) => `Paragraph ${index + 1}: ${paragraph}`, +).join('\n\n'); + +export const markdownComment = [ + '## A heading', + '', + 'Some **bold** text, a [link](https://daily.dev) and `inline code`.', + '', + '- first item', + '- second item', + '', + '```', + 'const answer = 42;', + '```', +].join('\n'); + +export const overflowingWord = `Supercalifragilisticexpialidocious${'antidisestablishmentarianism'.repeat( + 4, +)}`; diff --git a/packages/webapp/__tests__/FooterWrapper.tsx b/packages/webapp/__tests__/FooterWrapper.tsx new file mode 100644 index 00000000000..990820c5e8d --- /dev/null +++ b/packages/webapp/__tests__/FooterWrapper.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React, { useEffect } from 'react'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType } from '@dailydotdev/shared/src/graphql/posts'; +import type { Origin } from '@dailydotdev/shared/src/lib/log'; +import { + ActivePostContextProvider, + useActivePostContext, +} from '@dailydotdev/shared/src/contexts/ActivePostContext'; +import FooterWrapper from '../components/footer/FooterWrapper'; + +jest.mock('@dailydotdev/shared/src/components/ScrollToTopButton', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock( + '@dailydotdev/shared/src/components/post/MobilePostFloatingBar', + () => ({ + MobilePostFloatingBar: ({ + onCommentClick, + }: { + onCommentClick: (origin: string) => void; + }) => ( + onCommentClick('comment button')}> + Comment + + ), + }), +); + +const post = { id: 'p1', type: PostType.Article } as Post; + +const ComposerOwner = ({ + onOpenRequest, +}: { + onOpenRequest: (origin: Origin) => void; +}): null => { + const { onOpenCommentRequest } = useActivePostContext(); + + useEffect( + () => onOpenCommentRequest?.(onOpenRequest), + [onOpenCommentRequest, onOpenRequest], + ); + + return null; +}; + +describe('FooterWrapper', () => { + it('asks the in-page composer to open instead of mounting its own', async () => { + const onOpenRequest = jest.fn(); + render( + + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: 'Comment' })); + + expect(onOpenRequest).toHaveBeenCalledWith('comment button'); + }); + + it('renders no floating bar without a post', () => { + render(); + + expect( + screen.queryByRole('button', { name: 'Comment' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/webapp/__tests__/PostPage.tsx b/packages/webapp/__tests__/PostPage.tsx index b7a89417627..2f11dba0ec6 100644 --- a/packages/webapp/__tests__/PostPage.tsx +++ b/packages/webapp/__tests__/PostPage.tsx @@ -578,7 +578,7 @@ it('should send cancel upvote mutation', async () => { await waitFor(() => expect(mutationCalled).toBeTruthy()); }); -it('should open new comment modal and set the correct props', async () => { +it('should open the comment composer inline on the page', async () => { renderPost(); // Wait for GraphQL to return await screen.findByText('Learn SQL'); @@ -588,6 +588,22 @@ it('should open new comment modal and set the correct props', async () => { expect(commentBox).toBeInTheDocument(); }); +it('should open the comment composer when the mobile floating bar requests it', async () => { + renderPost(); + await screen.findByText('Learn SQL'); + + const commentButton = await waitFor(() => { + const el = document.getElementById('mobile-comment-post-btn'); + expect(el).toBeInTheDocument(); + return el; + }); + fireEvent.click(commentButton); + + expect( + await screen.findByRole('form', { name: 'Comment' }), + ).toBeInTheDocument(); +}); + it('should not show stats when they are zero', async () => { renderPost(); const el = screen.queryByTestId('statsBar'); diff --git a/packages/webapp/components/footer/FooterWrapper.tsx b/packages/webapp/components/footer/FooterWrapper.tsx index 5eecdddee67..b0e2755b5ea 100644 --- a/packages/webapp/components/footer/FooterWrapper.tsx +++ b/packages/webapp/components/footer/FooterWrapper.tsx @@ -6,12 +6,7 @@ import { PostType } from '@dailydotdev/shared/src/graphql/posts'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; import ScrollToTopButton from '@dailydotdev/shared/src/components/ScrollToTopButton'; - -const NewComment = dynamic(() => - import( - /* webpackChunkName: "newComment" */ '@dailydotdev/shared/src/components/post/NewComment' - ).then((mod) => mod.NewComment), -); +import { useActivePostContext } from '@dailydotdev/shared/src/contexts/ActivePostContext'; const MobilePostFloatingBar = dynamic(() => import( @@ -30,13 +25,6 @@ const MobileFooterNavbar = dynamic( import(/* webpackChunkName: "mobileFooterNavbar" */ './MobileFooterNavbar'), ); -const CommentInputOrModal = dynamic( - () => - import( - /* webpackChunkName: "commentInputOrModal" */ '@dailydotdev/shared/src/components/comments/CommentInputOrModal' - ), -); - interface FooterNavBarProps { showNav?: boolean; post?: Post; @@ -47,6 +35,7 @@ export default function FooterWrapper({ post, }: FooterNavBarProps): ReactElement { const router = useRouter(); + const { requestOpenComment } = useActivePostContext(); const showPlusButton = !router?.pathname?.startsWith('/settings') && @@ -66,16 +55,9 @@ export default function FooterWrapper({ {post && post.type !== PostType.Brief && ( - ( - - )} + onCommentClick={(origin) => requestOpenComment?.(origin)} /> )} diff --git a/packages/webapp/components/tools/ToolDiscussion.tsx b/packages/webapp/components/tools/ToolDiscussion.tsx index 7cd5dc5e76c..78f0368d920 100644 --- a/packages/webapp/components/tools/ToolDiscussion.tsx +++ b/packages/webapp/components/tools/ToolDiscussion.tsx @@ -29,10 +29,10 @@ import { CharmEmptyState } from '@dailydotdev/shared/src/components/charm/CharmE import { cloudinaryCharmNoComments } from '@dailydotdev/shared/src/lib/image'; import { PlusIcon } from '@dailydotdev/shared/src/components/icons'; -const CommentInputOrModal = dynamic( +const CommentInput = dynamic( () => import( - /* webpackChunkName: "commentInputOrModal" */ '@dailydotdev/shared/src/components/comments/CommentInputOrModal' + /* webpackChunkName: "commentInput" */ '@dailydotdev/shared/src/components/comments/CommentInput' ), ); @@ -150,11 +150,7 @@ export const ToolDiscussion = ({ } return ( - + ); };
{note}