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( + + + + , + ); + + 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( + + + , + ); + + 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} + + )} + + +
} 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={} + />, + ); + +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 ( - - - + ) : 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 && ( + +
+ ); + + 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 ? ( +