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.
This commit is contained in:
Tommaso Casaburi
2026-06-10 15:45:49 +07:00
parent 2f938d59ae
commit f3d59c3345
3 changed files with 54 additions and 22 deletions
@@ -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<HTMLInputElement>('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();
+22 -4
View File
@@ -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 {
+24 -17
View File
@@ -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<HTMLTextAreaElement | null>;
urlRef: React.Ref<HTMLInputElement>;
url: string;
lengthError: string | null;
handleContentChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => 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 && <div className={styles.error}>{lengthError}</div>}
</td>
</tr>
{flagOptions.length > 0 && (
@@ -505,6 +502,16 @@ const PostFormFields = ({
</>
);
const PostFormErrorRow = ({ ariaLive, children }: { ariaLive?: 'polite'; children: ReactNode }) => (
<tr className={styles.formErrorRow}>
<td colSpan={2}>
<div className={`${styles.error} ${styles.formError}`} aria-live={ariaLive}>
{children}
</div>
</td>
</tr>
);
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}
/>
</tbody>
<tfoot>
{moderationPostingWarning ? <PostFormErrorRow>{moderationPostingWarning}</PostFormErrorRow> : null}
{lengthError ? <PostFormErrorRow>{lengthError}</PostFormErrorRow> : null}
{youtubeThumbnailConversionCountdown !== null ? (
<PostFormErrorRow ariaLive='polite'>{t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown })}</PostFormErrorRow>
) : formError ? (
<PostFormErrorRow>
{isPostOptionsValidationError(formError) ? <PostOptionsErrorMessage error={formError} directories={directories} /> : formError}
</PostFormErrorRow>
) : null}
{publishPostError ? <PostFormErrorRow>{publishPostError}</PostFormErrorRow> : null}
{publishReplyError ? <PostFormErrorRow>{publishReplyError}</PostFormErrorRow> : null}
</tfoot>
</table>
{moderationPostingWarning ? <div className={`${styles.error} ${styles.formError}`}>{moderationPostingWarning}</div> : null}
{youtubeThumbnailConversionCountdown !== null ? (
<div className={`${styles.error} ${styles.formError}`} aria-live='polite'>
{t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown })}
</div>
) : formError ? (
<div className={`${styles.error} ${styles.formError}`}>
{isPostOptionsValidationError(formError) ? <PostOptionsErrorMessage error={formError} directories={directories} /> : formError}
</div>
) : null}
{publishPostError && <div className={`${styles.error} ${styles.formError}`}>{publishPostError}</div>}
{publishReplyError && <div className={`${styles.error} ${styles.formError}`}>{publishReplyError}</div>}
{publishReplyStateMessage && <div className={styles.status}>{publishReplyStateMessage}</div>}
</>
);