From 86e7d8eaf81005ae922f59ed441de638d114dad9 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 21 May 2026 14:38:02 +0200 Subject: [PATCH 1/2] fix: focus comment composer when ready --- .../MarkdownInput/CommentMarkdownInput.tsx | 17 ++- .../components/fields/RichTextInput.spec.tsx | 135 ++++++++++++++++++ .../src/components/fields/RichTextInput.tsx | 6 +- .../src/components/post/NewComment.spec.tsx | 91 ++++++++++++ .../shared/src/components/post/NewComment.tsx | 26 +++- 5 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 packages/shared/src/components/fields/RichTextInput.spec.tsx create mode 100644 packages/shared/src/components/post/NewComment.spec.tsx diff --git a/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx index 403fdb432e9..e0dd5547fcd 100644 --- a/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx +++ b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx @@ -1,8 +1,8 @@ import type { CSSProperties, + ForwardedRef, FormEventHandler, FormHTMLAttributes, - MutableRefObject, ReactElement, } from 'react'; import React, { forwardRef, useRef } from 'react'; @@ -25,6 +25,7 @@ export interface CommentClassName { export interface CommentMarkdownInputProps { post: Post; + inputId?: string; editCommentId?: string; parentCommentId?: string; initialContent?: string; @@ -38,6 +39,7 @@ export interface CommentMarkdownInputProps { ) => void; showSubmit?: boolean; showUserAvatar?: boolean; + autoFocus?: boolean; onChange?: (value: string) => void; formProps?: FormHTMLAttributes; onClose?: () => void; @@ -46,6 +48,7 @@ export interface CommentMarkdownInputProps { export function CommentMarkdownInputComponent( { post, + inputId, initialContent, replyTo, editCommentId, @@ -55,18 +58,23 @@ export function CommentMarkdownInputComponent( onChange, showSubmit = true, showUserAvatar = true, + autoFocus = true, formProps = {}, onClose, }: CommentMarkdownInputProps, - ref: MutableRefObject, + ref: ForwardedRef, ): ReactElement { - const shouldFocus = useRef(true); + const shouldFocus = useRef(autoFocus); const postId = post?.id; const sourceId = post?.source?.id; const { mutateComment: { mutateComment, isLoading, isSuccess }, } = useWriteCommentContext(); const richTextRef = useRef(null); + let submitCopy: string | undefined; + if (showSubmit) { + submitCopy = editCommentId ? 'Update' : 'Comment'; + } const onSubmitForm: FormEventHandler = async (e) => { e.preventDefault(); @@ -114,6 +122,7 @@ export function CommentMarkdownInputComponent( ref={ref} > { if (richTextRefInstance) { richTextRef.current = richTextRefInstance; @@ -143,7 +152,7 @@ export function CommentMarkdownInputComponent( }} onSubmit={onKeyboardSubmit} enabledCommand={{ ...defaultMarkdownCommands, upload: true }} - submitCopy={showSubmit && (editCommentId ? 'Update' : 'Comment')} + submitCopy={submitCopy} timeline={ replyTo ? ( diff --git a/packages/shared/src/components/fields/RichTextInput.spec.tsx b/packages/shared/src/components/fields/RichTextInput.spec.tsx new file mode 100644 index 00000000000..e03577d2d93 --- /dev/null +++ b/packages/shared/src/components/fields/RichTextInput.spec.tsx @@ -0,0 +1,135 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import RichTextInput from './RichTextInput'; + +const mockFocus = jest.fn(); +const mockUseEditor = jest.fn(); +const mockEditor = { + commands: { + focus: mockFocus, + setContent: jest.fn(), + }, +}; + +jest.mock('next/dynamic', () => () => () => null); + +jest.mock('@tiptap/core', () => ({ + Extension: { create: jest.fn((config) => config) }, + markInputRule: jest.fn(), + nodeInputRule: jest.fn(), +})); + +jest.mock('@tiptap/starter-kit', () => ({ + __esModule: true, + default: { configure: jest.fn(() => ({})) }, +})); + +jest.mock('@tiptap/extension-link', () => ({ + __esModule: true, + default: { configure: jest.fn(() => ({})) }, +})); + +jest.mock('@tiptap/extension-placeholder', () => ({ + __esModule: true, + default: { configure: jest.fn(() => ({})) }, +})); + +jest.mock('@tiptap/extension-character-count', () => ({ + __esModule: true, + default: { configure: jest.fn(() => ({})) }, +})); + +jest.mock('@tiptap/extension-image', () => ({ + __esModule: true, + default: {}, +})); + +jest.mock('@tiptap/react', () => ({ + __esModule: true, + useEditor: (options: unknown) => { + mockUseEditor(options); + return mockEditor; + }, + EditorContent: () => { + const react = jest.requireActual('react') as typeof React; + return react.createElement('div', { 'data-testid': 'editor-content' }); + }, +})); + +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: () => ({ user: null }), +})); + +jest.mock('../../hooks/usePopupSelector', () => ({ + __esModule: true, + usePopupSelector: () => ({ parentSelector: undefined }), +})); + +jest.mock('../../hooks/useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: jest.fn() }), +})); + +jest.mock('./RichTextEditor/useDraftStorage', () => ({ + useDraftStorage: () => ({ + getInitialValue: (initialContent = '') => initialContent, + clearDraft: jest.fn(), + }), +})); + +jest.mock('./RichTextEditor/useImageUpload', () => ({ + useImageUpload: () => ({ + queueCount: 0, + uploadRef: { current: null }, + insertImage: jest.fn(), + handleDrop: jest.fn(), + handlePaste: jest.fn(), + onUpload: jest.fn(), + }), +})); + +jest.mock('./RichTextEditor/useMentionAutocomplete', () => ({ + useMentionAutocomplete: () => ({ + queryRef: { current: undefined }, + mentionsRef: { current: [] }, + selectedRef: { current: 0 }, + mentions: [], + selected: 0, + query: undefined, + updateFromEditor: jest.fn(), + clearMention: jest.fn(), + applyMention: jest.fn(), + }), +})); + +jest.mock('./RichTextEditor/useEmojiAutocomplete', () => ({ + useEmojiAutocomplete: () => ({ + emojiQueryRef: { current: undefined }, + emojiDataRef: { current: [] }, + selectedEmojiRef: { current: 0 }, + emojiQuery: undefined, + emojiData: [], + selectedEmoji: 0, + updateFromEditor: jest.fn(), + clearEmoji: jest.fn(), + applyEmoji: jest.fn(), + setSelectedEmoji: jest.fn(), + }), +})); + +describe('RichTextInput', () => { + beforeEach(() => { + mockUseEditor.mockClear(); + }); + + it('exposes the input id on the rich editor DOM attributes', () => { + render(); + + expect(mockUseEditor).toHaveBeenCalledWith( + expect.objectContaining({ + editorProps: expect.objectContaining({ + attributes: { id: 'comment-editor' }, + }), + }), + ); + }); +}); diff --git a/packages/shared/src/components/fields/RichTextInput.tsx b/packages/shared/src/components/fields/RichTextInput.tsx index 97234a0b442..2a1eb9f2c88 100644 --- a/packages/shared/src/components/fields/RichTextInput.tsx +++ b/packages/shared/src/components/fields/RichTextInput.tsx @@ -122,6 +122,7 @@ interface ClassName { interface RichTextInputProps { className?: ClassName; + inputId?: string; footer?: ReactNode; textareaProps?: Omit< TextareaHTMLAttributes, @@ -169,6 +170,7 @@ export interface RichTextInputRef { function RichTextInput( { className = {}, + inputId, footer, textareaProps = {}, submitCopy, @@ -365,6 +367,7 @@ function RichTextInput( updateSuggestionsFromEditor(updatedEditor); }, editorProps: { + attributes: inputId ? { id: inputId } : undefined, handlePaste: (_view, event) => { const hasFiles = (event.clipboardData?.files?.length ?? 0) > 0; if (hasFiles) { @@ -671,7 +674,7 @@ function RichTextInput( return; } - editor?.commands.focus(); + editor?.commands.focus('end'); }, toggleMarkdownMode, })); @@ -863,6 +866,7 @@ function RichTextInput( >