본문으로 건너뛰기
블록

댓글 작성기

코멘트를 쓰는 상자예요. 남은 글자 수, 등록 버튼, 동의 안내가 함께 있어요.

  • #arc
  • #block
  • #comment
  • #form
  • #community
  • #textarea
  • #compose

설치

이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.

터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/comment-composer.json

comment-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.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;

사용된 항목