Block
Comment composer
The box for writing a comment, with the room left, the button and the notice.
- #arc
- #block
- #comment
- #form
- #community
- #textarea
- #compose
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/comment-composer.jsonFiles
components/arc/blocks/comment-composer/comment-composer.module.csscomponents/arc/blocks/comment-composer/comment-composer.tsx
Registry items it pulls in
Source
View as Markdowncomment-composer.module.csscomment-composer.module.csscomment-composer.module.csscss
.composer { display: flex; flex-direction: column; gap: var(--space-3); }
.signedOut { flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--space-3); border: 1px solid var(--border-subtle); border-radius: var(--radius-panel); padding: var(--space-4) var(--space-5); background: var(--surface); color: var(--foreground); font-size: var(--text-sm); }
.bar { display: flex; align-items: center; gap: var(--space-3); }
.counter { flex: 1 1 auto; color: var(--text-secondary); font-size: var(--text-xs); font-variant-numeric: tabular-nums; transition: color var(--duration-fast) var(--ease-standard); }
/* Only once the room is running out: a count that is always talking is never heard. */
.counter[data-low] { color: var(--warning); }
.notice { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; word-break: keep-all; }
/* Always in the tree, so a message that appears later is announced rather than silently added. */
.error:not(:empty) { margin: 0; color: var(--danger); font-size: var(--text-xs); line-height: 1.6; }
.error:empty { display: none; }
@media (prefers-reduced-motion: reduce) { .counter { transition: none; } }
comment-composer.tsxcomment-composer.tsxcomment-composer.tsxtsx
"use client";
import { useState } from "react";
import type { FormEvent, ReactNode } from "react";
import { Button } from "../../button/button";
import { Textarea } from "../../textarea/textarea";
import styles from "./comment-composer.module.css";
export interface CommentComposerProps {
/** Runs with the trimmed text. Resolve to true to clear the field. */
onSubmit?: (body: string) => void | boolean | Promise<void | boolean>;
maxLength?: number;
rows?: number;
placeholder?: string;
/** The accessible name of the field. */
label?: string;
submitLabel?: string;
/** Terms and privacy, under the button. */
notice?: ReactNode;
/** Shown instead of the field when nobody is signed in. */
signedOut?: ReactNode;
/** Said and announced when the post fails. */
error?: string;
busy?: boolean;
className?: string;
}
/**
* The box for writing a comment: the field, how much room is left, the button, and the line about what posting means.
*
* The counter counts **down**, and only the remaining number is worth reading — someone who has written forty
* characters of five hundred does not need to be told, so it stays quiet until the room runs low.
*/
export function CommentComposer({
onSubmit,
maxLength = 500,
rows = 3,
placeholder = "생각을 남겨 주세요",
label = "코멘트",
submitLabel = "등록하기",
notice,
signedOut,
error,
busy = false,
className,
}: CommentComposerProps) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const left = maxLength - text.length;
const empty = text.trim().length === 0;
if (signedOut) {
return <div className={[styles.composer, styles.signedOut, className].filter(Boolean).join(" ")}>{signedOut}</div>;
}
async function submit(event: FormEvent) {
event.preventDefault();
if (empty || sending || busy) return;
setSending(true);
try {
const cleared = await onSubmit?.(text.trim());
if (cleared !== false) setText("");
} finally {
setSending(false);
}
}
return (
<form onSubmit={submit} className={[styles.composer, className].filter(Boolean).join(" ")}>
<Textarea
label={label}
placeholder={placeholder}
rows={rows}
maxLength={maxLength}
value={text}
onChange={event => setText(event.target.value)}
/>
<div className={styles.bar}>
{/* Counts down, and only speaks up near the end: a count that is always talking is never heard. */}
<span className={styles.counter} data-low={left <= Math.min(50, maxLength / 10) ? "" : undefined} aria-live="polite">
{`${left}자 남음`}
</span>
<Button type="submit" variant="primary" size="sm" disabled={empty} loading={sending || busy}>
{submitLabel}
</Button>
</div>
{notice ? <p className={styles.notice}>{notice}</p> : null}
<p role="alert" className={styles.error}>{error ?? ""}</p>
</form>
);
}
CommentComposer.displayName = "CommentComposer";
export default CommentComposer;