From f3d59c3345103d87797d81d752be41300c623206 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Wed, 10 Jun 2026 15:45:49 +0700 Subject: [PATCH] fix(post-form): style errors like 4chan and move into tfoot Use red banner styling for form errors and render them in the table footer so messages stay aligned with the post form layout. --- .../post-form/__tests__/post-form.test.tsx | 9 +++- src/components/post-form/post-form.module.css | 26 ++++++++++-- src/components/post-form/post-form.tsx | 41 +++++++++++-------- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index 1a428fa3..a00529f1 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -627,6 +627,9 @@ describe('PostForm', () => { await clickByText(table as HTMLTableElement, 'post'); expect(globalThis.alert).not.toHaveBeenCalled(); expect(container.textContent).toContain('error: empty_comment_alert'); + expect(table?.querySelector('tbody tr.rules')?.textContent).toContain('Please read the Rules and FAQ before posting.'); + expect(table?.querySelector('tfoot')?.textContent).toContain('error: empty_comment_alert'); + expect(table?.querySelector('tbody')?.nextElementSibling).toBe(table?.querySelector('tfoot')); const textInputs = table?.querySelectorAll('input[type="text"]') || []; const nameInput = textInputs[0]; @@ -710,8 +713,9 @@ describe('PostForm', () => { expect(linkInput.disabled).toBe(true); expect(container.textContent).toContain('youtube_thumbnail_link_conversion_notice'); expect(container.textContent).toContain('3'); - expect(table.textContent).not.toContain('youtube_thumbnail_link_conversion_notice'); + expect(table.textContent).toContain('youtube_thumbnail_link_conversion_notice'); expect(getConversionNotice()?.className).toContain('formError'); + expect(getConversionNotice()?.closest('tfoot')).toBeTruthy(); await act(async () => { vi.advanceTimersByTime(1000); @@ -1059,6 +1063,7 @@ describe('PostForm', () => { const delayedOptionsError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'Unsupported options: x, y, z.'); expect(delayedOptionsError?.className).toContain('error'); expect(delayedOptionsError?.className).toContain('formError'); + expect(delayedOptionsError?.closest('tfoot')).toBeTruthy(); await dispatchInput(textarea as HTMLTextAreaElement, 'fortune body'); await clickByText(table as HTMLTableElement, 'post'); @@ -1096,6 +1101,7 @@ describe('PostForm', () => { expect(testState.publishPostOptions.content).toBe(longContent); expect(container.textContent).toContain('comment_field_too_long'); + expect(table.querySelector('tfoot')?.textContent).toContain('comment_field_too_long'); expect(container.textContent).not.toContain('[fortune color='); expect(testState.publishPostMock).not.toHaveBeenCalled(); }); @@ -1253,6 +1259,7 @@ describe('PostForm', () => { const moderatorWarning = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'warning: posting as moderator'); expect(moderatorWarning?.className).toContain('error'); expect(moderatorWarning?.className).toContain('formError'); + expect(moderatorWarning?.closest('tfoot')).toBeTruthy(); expect(table?.textContent).not.toContain('Mod editor'); expect(table?.textContent).not.toContain('mods only'); expect(table?.querySelector('button[aria-label="Quote"]')).toBeNull(); diff --git a/src/components/post-form/post-form.module.css b/src/components/post-form/post-form.module.css index 92067da8..3cd8b492 100644 --- a/src/components/post-form/post-form.module.css +++ b/src/components/post-form/post-form.module.css @@ -236,9 +236,15 @@ } .error { + display: block; + margin: 3px 0 0; + padding: 3px 5px; + background-color: #e62020; + color: #fff; + font-family: monospace; + font-size: 13px; font-weight: bold; - padding: 5px; - color: red; + line-height: normal; } .error a, @@ -248,8 +254,20 @@ } .formError { - margin: 8px 0; - padding: 6px 5px; + box-sizing: border-box; + width: 468px; + max-width: calc(100vw - 2px); + text-align: start; +} + +.postFormTable .formErrorRow td { + width: auto; + padding: 0; + border: 0; + background-color: transparent; + color: inherit; + font-weight: normal; + text-transform: none; } .status { diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index cd3a7f87..2db00c36 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; @@ -154,7 +154,6 @@ interface PostFormFieldsProps { textRef: React.RefObject; urlRef: React.Ref; url: string; - lengthError: string | null; handleContentChange: (e: React.ChangeEvent) => void; handleContentValueChange: (content: string, options?: string) => void; handleLinkChange: (link: string) => void; @@ -207,7 +206,6 @@ const PostFormFields = ({ textRef, urlRef, url, - lengthError, handleContentChange, handleContentValueChange, handleLinkChange, @@ -336,7 +334,6 @@ const PostFormFields = ({ hidden={showBbcodeToolbar && isBbcodePreviewing} onChange={handleContentChange} /> - {lengthError &&
{lengthError}
} {flagOptions.length > 0 && ( @@ -505,6 +502,16 @@ const PostFormFields = ({ ); +const PostFormErrorRow = ({ ariaLive, children }: { ariaLive?: 'polite'; children: ReactNode }) => ( + + +
+ {children} +
+ + +); + const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: string }) => { const { t } = useTranslation(); const params = useParams(); @@ -905,7 +912,6 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: textRef={textRef} urlRef={urlRef} url={url} - lengthError={lengthError} handleContentChange={handleContentChange} handleContentValueChange={handleContentValueChange} handleLinkChange={handleLinkChange} @@ -943,19 +949,20 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: disableReplyPublish={isResolvingExternalQuotes} /> + + {moderationPostingWarning ? {moderationPostingWarning} : null} + {lengthError ? {lengthError} : null} + {youtubeThumbnailConversionCountdown !== null ? ( + {t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown })} + ) : formError ? ( + + {isPostOptionsValidationError(formError) ? : formError} + + ) : null} + {publishPostError ? {publishPostError} : null} + {publishReplyError ? {publishReplyError} : null} + - {moderationPostingWarning ?
{moderationPostingWarning}
: null} - {youtubeThumbnailConversionCountdown !== null ? ( -
- {t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown })} -
- ) : formError ? ( -
- {isPostOptionsValidationError(formError) ? : formError} -
- ) : null} - {publishPostError &&
{publishPostError}
} - {publishReplyError &&
{publishReplyError}
} {publishReplyStateMessage &&
{publishReplyStateMessage}
} );