コメントスレッド画面(投稿・返信)
この画面パターンについて
案件管理・申請ワークフローなどに組み込まれる「コメント機能」の画面パターンです。
コメント一覧・1階層の返信・文字数カウンター付き投稿フォーム・投稿後の自動スクロールまで一式を実装します。
ユーザー入力をそのまま画面に表示するUIのため、textContent によるXSS安全な描画を最重要の実装ポイントとして扱います。
こんな場面で使えます
- 案件・タスクへのコメント — 担当者間のやり取りを記録する
- 申請・承認のやり取り — 差し戻し理由や補足を申請に紐づける
- 社内ナレッジへのフィードバック — 記事や資料に質問・補足を付ける
この画面で使っているUIコンポーネント
| # | パーツ | この画面での役割 |
|---|---|---|
| 1 | コメントリスト | アバター・名前・相対日時・本文の表示 |
| 2 | 返信表示(1階層ネスト) | インデント+縦ラインで親子を表現 |
| 3 | インライン返信フォーム | 「返信する」で直下に出現(同時に1つ) |
| 4 | 自動拡張テキストエリア | 入力に合わせて高さが伸びる(最大6行) |
| 5 | 文字数カウンター | 「128/300」表示・超過で赤字 |
| 6 | 投稿ボタンの活性制御 | 空・超過時は disabled |
| 7 | 自動スクロール+ハイライト | 投稿位置を視覚的にフィードバック |
| 8 | 削除(プレースホルダー方式) | 確認後「削除されました」に置き換え |
実装のポイント・注意点
本サイトのXSS対策ルールの「実演編」です。コメント本文を innerHTML で挿入すると <script> や <img onerror> がそのまま実行される脆弱性になります。
必ず createElement + textContent で挿入し、改行はCSSの white-space: pre-wrap で表現することで、HTMLとして解釈させずに見た目を保てます。
データ構造は親子をネストさせず、parentId を持つフラットな配列にすると追加・削除のロジックが単純になります。
削除は配列から取り除かず deleted: true フラグで「削除されました」表示に置き換え、返信付きコメントを消してもスレッド構造が壊れないようにします。
8個のUIコンポーネントをHTML・CSS・バニラJavaScriptのみで組み合わせており、 フレームワーク不要で画面ごとコピペして使えます。
動作サンプル
動作サンプルを別ウィンドウで確認 ↗
試してみる:
- コメントを投稿して、自動スクロールとハイライトのフィードバックを確認
<script>alert(1)</script>を投稿して、無害なテキストとして表示されること(XSS対策)を確認- 自分のコメントを削除して、「削除されました」表示に置き換わり返信が残ることを確認
そのほかの操作も自由に試してみてください。
サンプルソース
3つのファイルを同じフォルダに保存し、ブラウザで index.html を開くと動作確認できます。
ファイル名:index.html / style.css / script.js
保存時の文字コードは UTF-8 を指定してください。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>コメントスレッド — デモ</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<!-- ===== メイン画面 ===== -->
<div class="thread-screen">
<h1 class="thread-title">コメント(<span id="commentCount">5</span>件)</h1>
<!-- コメントリスト(JSで生成) -->
<ol class="comment-list" id="commentList" aria-label="コメント一覧"></ol>
<!-- 新規投稿フォーム -->
<div class="post-form">
<span class="comment-avatar is-mine-avatar" aria-hidden="true">太</span>
<div class="post-form-main">
<textarea
class="post-textarea"
id="postTextarea"
rows="2"
placeholder="コメントを入力(Ctrl+Enter で投稿)"
aria-label="コメントを入力"
maxlength="1000"
></textarea>
<div class="post-form-footer">
<span class="char-counter" id="charCounter" aria-live="polite">0/300</span>
<button type="button" class="btn-primary" id="postBtn" disabled>投稿する</button>
</div>
</div>
</div>
</div>
<!-- 返信フォームテンプレート(JSで複製して各コメントに挿入) -->
<template id="replyFormTemplate">
<div class="reply-form">
<span class="comment-avatar is-mine-avatar" aria-hidden="true">太</span>
<div class="post-form-main">
<textarea
class="reply-textarea"
rows="2"
placeholder="返信を入力(Ctrl+Enter で投稿)"
aria-label="返信を入力"
maxlength="1000"
></textarea>
<div class="post-form-footer">
<span class="char-counter">0/300</span>
<div class="reply-form-actions">
<button type="button" class="btn-cancel">キャンセル</button>
<button type="button" class="btn-primary" disabled>返信する</button>
</div>
</div>
</div>
</div>
</template>
<!-- 削除確認ダイアログ -->
<div class="confirm-overlay" id="confirmOverlay" hidden role="dialog" aria-modal="true" aria-labelledby="confirmMessage">
<div class="confirm-dialog">
<p class="confirm-message" id="confirmMessage">このコメントを削除しますか?</p>
<div class="confirm-actions">
<button type="button" class="btn-cancel" id="confirmCancel">キャンセル</button>
<button type="button" class="btn-danger" id="confirmOk">削除する</button>
</div>
</div>
</div>
<script src="./script.js"></script>
</body>
</html>
/* ===== リセット・ベース ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-bg: #F7F9FC;
--color-surface: #FFFFFF;
--color-border: #E3E8EF;
--color-text: #1A202C;
--color-text-sub: #718096;
--color-primary: #2B7FE8;
--color-danger: #EF4444;
--color-avatar-bg: #CBD5E0;
--color-avatar-mine: #2B7FE8;
--color-highlight: #FFF8DC;
--color-deleted: #A0AEC0;
--color-reply-line: #E3E8EF;
--radius: 8px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font);
font-size: 14px;
line-height: 1.6;
padding: 24px 16px 48px;
}
/* ===== スレッド画面ラッパー ===== */
.thread-screen {
max-width: 640px;
margin: 0 auto;
}
.thread-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border);
}
/* ===== コメントリスト ===== */
.comment-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0;
margin-bottom: 24px;
}
/* ===== コメントアイテム ===== */
.comment-item {
display: flex;
flex-direction: column;
padding: 14px 0;
border-bottom: 1px solid var(--color-border);
}
/* アバター+本文の横並び行 */
.comment-row { display: flex; gap: 12px; }
/* ハイライトアニメーション */
@keyframes fadeHighlight {
0% { background: var(--color-highlight); }
100% { background: transparent; }
}
.is-highlight {
animation: fadeHighlight 2s ease-out;
border-radius: var(--radius);
}
/* ===== アバター ===== */
.comment-avatar {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--color-avatar-bg);
color: #fff;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.is-mine-avatar { background: var(--color-avatar-mine); }
/* ===== コメント本体 ===== */
.comment-main { flex: 1; min-width: 0; }
.comment-meta {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 4px;
}
.comment-author { font-weight: 600; font-size: 13px; }
.comment-time { font-size: 12px; color: var(--color-text-sub); }
/* 本文:white-space: pre-wrap で改行をCSSに任せる(innerHTML不使用) */
.comment-body {
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
font-size: 14px;
margin-bottom: 8px;
}
/* 削除済みコメント */
.is-deleted .comment-body { color: var(--color-deleted); font-style: italic; }
/* ===== アクションボタン ===== */
.comment-actions { display: flex; gap: 12px; }
.btn-reply,
.btn-delete {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 2px 0;
color: var(--color-text-sub);
transition: color 0.15s;
}
.btn-reply:hover { color: var(--color-primary); }
.btn-delete:hover { color: var(--color-danger); }
/* ===== 返信リスト(1階層ネスト) ===== */
.reply-list {
list-style: none;
margin-left: 48px;
border-left: 2px solid var(--color-reply-line);
padding-left: 16px;
}
.reply-list .comment-item { border-bottom: none; padding: 10px 0 6px; }
.reply-list .comment-item:not(:last-child) { border-bottom: 1px solid var(--color-border); }
.reply-form-slot { margin-left: 48px; }
/* ===== インライン返信フォーム ===== */
.reply-form { display: flex; gap: 12px; padding: 12px 0 4px; }
.reply-form-actions { display: flex; gap: 8px; }
/* ===== 新規投稿フォーム ===== */
.post-form {
display: flex;
gap: 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 14px;
}
.post-form-main { flex: 1; min-width: 0; }
/* ===== テキストエリア(共通) ===== */
.post-textarea,
.reply-textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
resize: none;
overflow-y: hidden;
/* 最大6行:14px × 1.5 × 6 + padding × 2 */
max-height: calc(14px * 1.5 * 6 + 18px);
transition: border-color 0.15s;
}
.post-textarea:focus,
.reply-textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(43, 127, 232, 0.12);
}
/* ===== フォームフッター ===== */
.post-form-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
gap: 8px;
}
/* ===== 文字数カウンター ===== */
.char-counter { font-size: 12px; color: var(--color-text-sub); flex-shrink: 0; }
.char-counter.over { color: var(--color-danger); font-weight: 600; }
/* ===== ボタン ===== */
.btn-primary {
padding: 7px 16px;
font-size: 13px;
font-weight: 600;
color: #fff;
background: var(--color-primary);
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
flex-shrink: 0;
}
.btn-primary:hover:not(:disabled) { background: #1A6FD4; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-cancel {
padding: 7px 14px;
font-size: 13px;
color: var(--color-text-sub);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
}
.btn-cancel:hover { background: var(--color-bg); }
/* ===== 削除確認ダイアログ ===== */
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.confirm-overlay[hidden] { display: none; }
.confirm-dialog {
background: var(--color-surface);
border-radius: var(--radius);
padding: 24px;
width: min(360px, calc(100vw - 32px));
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
}
.confirm-message { font-size: 15px; margin-bottom: 20px; color: var(--color-text); }
.confirm-actions { display: flex; justify-content: flex-end; gap: 10px; }
.btn-danger {
padding: 7px 16px;
font-size: 13px;
font-weight: 600;
color: #fff;
background: var(--color-danger);
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.btn-danger:hover { background: #DC2626; }
/* ===== レスポンシブ ===== */
@media (max-width: 480px) {
body { padding: 16px 12px 40px; }
.reply-list { margin-left: 32px; padding-left: 12px; }
.reply-form-slot { margin-left: 32px; }
}
/* =====================================================
コメントスレッド画面のスクリプト
仕組み:comments 配列(parentId 方式・フラット)が唯一の情報源。
投稿・返信・削除は配列を変更して renderAll() を呼ぶだけ。
renderAll() が親→返信の順でリストを組み立て直す。
XSS対策:本文・名前は必ず createElement + textContent で挿入。
改行は white-space: pre-wrap で表現(innerHTML に変数を結合しない)。
===================================================== */
// ===== 設定値 =====
var MAX_CHARS = 300; // 投稿できる最大文字数
var MY_NAME = 'サンプル 太郎'; // 自分の投稿者名(削除ボタン表示判定に使用)
var DELETED_TEXT = 'このコメントは削除されました'; // 削除後の表示文字列
// ===== データ =====
// parentId 方式のフラット配列。返信は parentId に親の id を持つ。
// deleted:true にしても配列から消さない → 返信スレッドを保持するため。
var comments = [
{ id: 1, parentId: null, author: '利用者A', minutesAgo: 120, body: '最初のコメントのサンプルです。', deleted: false, mine: false },
{ id: 2, parentId: 1, author: '利用者B', minutesAgo: 60, body: '返信のサンプルです。', deleted: false, mine: false },
{ id: 3, parentId: null, author: '利用者C', minutesAgo: 30, body: '2つ目のコメントです。\n改行も表示できます。', deleted: false, mine: false },
{ id: 4, parentId: 3, author: MY_NAME, minutesAgo: 10, body: '自分の返信(削除ボタン付き)です。', deleted: false, mine: true },
{ id: 5, parentId: null, author: '利用者B', minutesAgo: 5, body: '3つ目のコメントです。', deleted: false, mine: false }
];
var nextId = 6; // 新規投稿で採番する ID
var pendingDeleteId = null; // 削除確認ダイアログで「削除する」を押したときのターゲット
// ===== 相対時刻ヘルパー =====
// minutesAgo(分)→「たった今」「5分前」「2時間前」「昨日」「3日前」
function formatRelativeTime(minutesAgo) {
if (minutesAgo < 1) return 'たった今';
if (minutesAgo < 60) return minutesAgo + '分前';
var hours = Math.floor(minutesAgo / 60);
if (hours < 24) return hours + '時間前';
if (hours < 48) return '昨日';
return Math.floor(hours / 24) + '日前';
}
// ===== アバター生成 =====
function createAvatar(author, isMine) {
var el = document.createElement('span');
el.className = 'comment-avatar' + (isMine ? ' is-mine-avatar' : '');
el.setAttribute('aria-hidden', 'true');
el.textContent = Array.from(author)[0]; // 全角でも安全に最初の1文字
return el;
}
// ===== 1件分のコメント li 生成 =====
function createCommentEl(comment) {
var li = document.createElement('li');
li.className = 'comment-item' + (comment.mine ? ' is-mine' : '');
li.dataset.id = comment.id;
var row = document.createElement('div');
row.className = 'comment-row';
row.appendChild(createAvatar(comment.author, comment.mine));
var main = document.createElement('div');
main.className = 'comment-main';
// メタ(名前・時刻)
var meta = document.createElement('div');
meta.className = 'comment-meta';
var authorEl = document.createElement('span');
authorEl.className = 'comment-author';
authorEl.textContent = comment.author; // textContent でXSS防止
var timeEl = document.createElement('span');
timeEl.className = 'comment-time';
timeEl.textContent = formatRelativeTime(comment.minutesAgo);
meta.appendChild(authorEl);
meta.appendChild(timeEl);
main.appendChild(meta);
// 本文(削除済みかどうかで分岐)
var body = document.createElement('p');
body.className = 'comment-body';
if (comment.deleted) {
li.classList.add('is-deleted');
body.textContent = DELETED_TEXT; // textContent でXSS防止
} else {
body.textContent = comment.body; // textContent でXSS防止(改行は pre-wrap で表現)
}
main.appendChild(body);
// アクションボタン(削除済みは非表示)
if (!comment.deleted) {
var actions = document.createElement('div');
actions.className = 'comment-actions';
// 「返信する」は親コメントのみ(返信への返信は最大1階層まで)
if (comment.parentId === null) {
var replyBtn = document.createElement('button');
replyBtn.type = 'button';
replyBtn.className = 'btn-reply';
replyBtn.textContent = '返信する';
replyBtn.dataset.replyTo = comment.id;
actions.appendChild(replyBtn);
}
// 「削除」は自分のコメントのみ
if (comment.mine) {
var delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'btn-delete';
delBtn.textContent = '削除';
delBtn.dataset.deleteId = comment.id;
actions.appendChild(delBtn);
}
main.appendChild(actions);
}
row.appendChild(main);
li.appendChild(row);
// 返信リスト(このコメントへの返信を入れる ol)
var replyList = document.createElement('ol');
replyList.className = 'reply-list';
replyList.dataset.parentId = comment.id;
li.appendChild(replyList);
// 返信フォームの挿入先スロット
var slot = document.createElement('div');
slot.className = 'reply-form-slot';
slot.dataset.slotFor = comment.id;
li.appendChild(slot);
return li;
}
// ===== 全体を再描画 =====
function renderAll() {
var list = document.getElementById('commentList');
list.innerHTML = '';
// 親コメントのみ先に描画し、その直後に返信を追加
var roots = comments.filter(function(c) { return c.parentId === null; });
roots.forEach(function(c) {
var li = createCommentEl(c);
list.appendChild(li);
var replyList = li.querySelector('.reply-list');
var replies = comments.filter(function(r) { return r.parentId === c.id; });
replies.forEach(function(r) { replyList.appendChild(createCommentEl(r)); });
});
document.getElementById('commentCount').textContent = comments.length;
}
// ===== 文字数バリデーション・自動拡張(テキストエリア共通) =====
function updateCounter(textarea, counterEl, submitBtn) {
var len = textarea.value.length;
var over = len > MAX_CHARS;
counterEl.textContent = len + '/' + MAX_CHARS;
counterEl.classList.toggle('over', over);
submitBtn.disabled = textarea.value.trim().length === 0 || over;
// height: auto → scrollHeight で縮小にも対応
textarea.style.height = 'auto';
var sh = textarea.scrollHeight;
var maxH = parseFloat(getComputedStyle(textarea).maxHeight);
textarea.style.height = (sh >= maxH ? maxH : sh) + 'px';
textarea.style.overflowY = sh >= maxH ? 'auto' : 'hidden';
}
// ===== 投稿後ハイライト =====
function highlightEl(el) {
el.classList.remove('is-highlight');
void el.offsetWidth; // 強制リフロー(アニメーションを再起動するため)
el.classList.add('is-highlight');
el.addEventListener('animationend', function handler() {
el.classList.remove('is-highlight');
el.removeEventListener('animationend', handler);
});
}
// ===== 返信フォームを開く =====
// 別のフォームが開いていたら先に閉じる(同時に1つだけ)
function openReplyForm(parentId) {
closeAllReplyForms();
var slot = document.querySelector('.reply-form-slot[data-slot-for="' + parentId + '"]');
if (!slot) return;
var form = document.getElementById('replyFormTemplate').content.cloneNode(true).firstElementChild;
var textarea = form.querySelector('.reply-textarea');
var counterEl = form.querySelector('.char-counter');
var submitBtn = form.querySelector('.btn-primary');
var cancelBtn = form.querySelector('.btn-cancel');
textarea.addEventListener('input', function() { updateCounter(textarea, counterEl, submitBtn); });
// Ctrl+Enter → 返信投稿
textarea.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !submitBtn.disabled) {
postReply(parentId, textarea.value);
}
});
submitBtn.addEventListener('click', function() { postReply(parentId, textarea.value); });
cancelBtn.addEventListener('click', closeAllReplyForms);
slot.appendChild(form);
textarea.focus();
}
function closeAllReplyForms() {
document.querySelectorAll('.reply-form').forEach(function(f) { f.remove(); });
}
// ===== 返信を投稿 =====
function postReply(parentId, text) {
if (!text.trim() || text.length > MAX_CHARS) return;
var newComment = { id: nextId++, parentId: parentId, author: MY_NAME, minutesAgo: 0, body: text, deleted: false, mine: true };
comments.push(newComment);
closeAllReplyForms();
renderAll();
var newEl = document.querySelector('.comment-item[data-id="' + newComment.id + '"]');
if (newEl) { newEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); highlightEl(newEl); }
}
// ===== 削除確認ダイアログ =====
function openConfirm(commentId) {
pendingDeleteId = commentId;
document.getElementById('confirmOverlay').hidden = false;
document.getElementById('confirmOk').focus();
}
function closeConfirm() {
pendingDeleteId = null;
document.getElementById('confirmOverlay').hidden = true;
}
// ===== コメントを削除(フラグ方式) =====
// 配列から取り除かず deleted: true にする → 返信があってもスレッド構造が壊れない
function deleteComment(commentId) {
var target = comments.find(function(c) { return c.id === commentId; });
if (target) { target.deleted = true; renderAll(); }
}
// ===== イベント委譲 =====
// コメントリスト全体で「返信する」「削除」のクリックをまとめて受ける
document.getElementById('commentList').addEventListener('click', function(e) {
var replyBtn = e.target.closest('.btn-reply');
if (replyBtn) { openReplyForm(Number(replyBtn.dataset.replyTo)); return; }
var deleteBtn = e.target.closest('.btn-delete');
if (deleteBtn) { openConfirm(Number(deleteBtn.dataset.deleteId)); }
});
// ===== 削除確認ダイアログのボタン =====
document.getElementById('confirmOk').addEventListener('click', function() {
if (pendingDeleteId !== null) { deleteComment(pendingDeleteId); }
closeConfirm();
});
document.getElementById('confirmCancel').addEventListener('click', closeConfirm);
// オーバーレイ背景クリックでも閉じる
document.getElementById('confirmOverlay').addEventListener('click', function(e) {
if (e.target === this) closeConfirm();
});
// ===== 新規投稿フォーム =====
var postTextarea = document.getElementById('postTextarea');
var charCounter = document.getElementById('charCounter');
var postBtn = document.getElementById('postBtn');
postTextarea.addEventListener('input', function() { updateCounter(postTextarea, charCounter, postBtn); });
// Enterのみ→改行 / Ctrl+Enter→投稿(コメント欄の作法。チャットはEnter=送信が多いが意図的に逆)
postTextarea.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !postBtn.disabled) { submitPost(); }
});
postBtn.addEventListener('click', submitPost);
function submitPost() {
var text = postTextarea.value;
if (!text.trim() || text.length > MAX_CHARS) return;
var newComment = { id: nextId++, parentId: null, author: MY_NAME, minutesAgo: 0, body: text, deleted: false, mine: true };
comments.push(newComment);
// フォームをリセット
postTextarea.value = '';
postTextarea.style.height = '';
charCounter.textContent = '0/' + MAX_CHARS;
charCounter.classList.remove('over');
postBtn.disabled = true;
renderAll();
var newEl = document.querySelector('.comment-item[data-id="' + newComment.id + '"]');
if (newEl) { newEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); highlightEl(newEl); }
}
// ===== 初期描画 =====
renderAll();
AI用プロンプト
以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。
ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や列数変更など、要件を追記して使うのがおすすめです。
※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。
💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。
# コメントスレッド画面 作成依頼
## 概要
コメント一覧・返信・投稿フォームからなるコメント欄UIを作成してください。
ユーザー入力を安全に表示すること(XSS対策)を最優先してください。
## 要件
- コメントは アバター(イニシャルの丸)・名前・相対時刻(「5分前」等)・本文 で構成し、
古い順に表示する。初期データは5件(うち返信2件)
- 返信は1階層まで。親コメントの下にインデント+左の縦ラインで表示する
- 各コメントの「返信する」でそのコメント直下にインライン返信フォームを開く。
同時に開ける返信フォームは1つだけで、別の「返信する」を押すと前のフォームは閉じる
- 画面下部に新規投稿フォーム(テキストエリア+文字数カウンター+投稿ボタン)を置く
- テキストエリアは入力に応じて高さが自動で伸びる(最大6行、それ以上は内部スクロール)
- 文字数カウンターは「128/300」形式。空白のみ・300文字超のときは投稿ボタンを無効にし、
超過時はカウンターを赤字にする
- Ctrl+Enter(Macは⌘+Enter)でも投稿できる。Enterだけでは改行する
- 投稿後はフォームをクリアし、追加されたコメントへスムーズスクロールして
背景色が2秒でフェードアウトするハイライトを付ける
- 自分のコメント(名前「サンプル 太郎」で投稿されるもの)にだけ削除ボタンを表示し、
確認ダイアログを経て本文を「このコメントは削除されました」のグレー表示に置き換える
(返信が付いていてもスレッド構造を保つ)
## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- コメントデータはJavaScript内の配列(parentId方式)で保持する
- レスポンシブ対応:必要
## 動作詳細
- 本文・名前の表示は必ず createElement と textContent で行い、innerHTML に
ユーザー入力を結合しない(<script>タグを投稿しても文字列として表示されること)
- 本文の改行は white-space: pre-wrap で表現し、<br>への変換は行わない
- 相対時刻は投稿からの経過分数をもとに「たった今/n分前/n時間前/昨日/n日前」を表示する
## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。