Skip to content

Commit 323ccf7

Browse files
committed
Add threaded comment replies with collapsible UI
- Add Facebook-style thread connector lines for nested replies - Implement collapsible replies with accessible toggle showing descendant count - Cap horizontal indentation at depth 2 to prevent layout issues on small screens - Add `repliesCount()` method to recursively count all descendant comments - Update CSS for thread lines with dark mode support - Add English translations for replies UI
1 parent 42f1e28 commit 323ccf7

7 files changed

Lines changed: 240 additions & 14 deletions

File tree

resources/css/commentions.css

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,54 @@
4444
.mention-item:hover {
4545
@apply comm:bg-gray-100;
4646
}
47+
48+
/*
49+
* Threaded reply connector — a Facebook-style curved elbow line.
50+
* Rendered by the `.commentions-thread` element inside each reply (depth > 0),
51+
* which sits absolutely inside the reply row's left padding gutter.
52+
*/
53+
.commentions-thread {
54+
position: absolute;
55+
inset: 0 auto 0 0;
56+
width: 1.5rem;
57+
pointer-events: none;
58+
}
59+
60+
.commentions-thread::before,
61+
.commentions-thread::after {
62+
content: "";
63+
position: absolute;
64+
left: 0.5rem;
65+
}
66+
67+
/* Vertical trunk running the full height of the reply row. */
68+
.commentions-thread::before {
69+
top: 0;
70+
bottom: 0;
71+
width: 2px;
72+
background-color: #d1d5db;
73+
}
74+
75+
/* Curved elbow branching off the trunk into the reply's avatar. */
76+
.commentions-thread::after {
77+
top: 0;
78+
width: 1rem;
79+
height: 1.5rem;
80+
border-left: 2px solid #d1d5db;
81+
border-bottom: 2px solid #d1d5db;
82+
border-bottom-left-radius: 0.75rem;
83+
}
84+
85+
/* The last reply ends the trunk at its own elbow rather than running on. */
86+
.commentions-replies > :last-child .commentions-thread::before {
87+
bottom: auto;
88+
height: 1.5rem;
89+
}
90+
91+
:where(.dark, .dark *) .commentions-thread::before {
92+
background-color: #4b5563;
93+
}
94+
95+
:where(.dark, .dark *) .commentions-thread::after {
96+
border-color: #4b5563;
97+
}

resources/dist/commentions.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/lang/en/comments.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
'cancel' => 'Cancel',
1818
'delete' => 'Delete',
1919
'reply' => 'Reply',
20+
'replies_count' => '{0} No replies|{1} :count reply|[2,*] :count replies',
21+
'hide_replies' => 'Hide replies',
2022
'save' => 'Save',
2123
'comment' => 'Comment',
2224
'add_reaction' => 'Add Reaction',

resources/views/comment.blade.php

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,33 @@
11
@use('\Kirschbaum\Commentions\Config')
22

3-
<div class="comm:flex comm:items-start comm:gap-x-4 comm:border comm:border-gray-300 comm:dark:border-gray-700 comm:p-4 comm:rounded-lg comm:shadow-sm comm:mb-2" id="filament-comment-{{ $comment->getId() }}">
3+
<div
4+
@class([
5+
'comm:flex comm:items-start',
6+
'comm:gap-x-4 comm:border comm:border-gray-300 comm:dark:border-gray-700 comm:p-4 comm:rounded-lg comm:shadow-sm comm:mb-2' => $depth === 0,
7+
'comm:relative comm:gap-x-3 comm:py-2 comm:pl-6' => $depth > 0,
8+
])
9+
id="filament-comment-{{ $comment->getId() }}"
10+
>
11+
@if ($depth > 0)
12+
<div class="commentions-thread" aria-hidden="true"></div>
13+
@endif
14+
415
@if ($avatar = $comment->getAuthorAvatar())
516
<img
617
src="{{ $comment->getAuthorAvatar() }}"
718
alt="{{ __('commentions::comments.user_avatar_alt') }}"
8-
class="comm:w-10 comm:h-10 comm:rounded-full comm:mt-0.5 comm:object-cover comm:object-center"
19+
@class([
20+
'comm:rounded-full comm:mt-0.5 comm:object-cover comm:object-center',
21+
'comm:w-10 comm:h-10' => $depth === 0,
22+
'comm:w-7 comm:h-7' => $depth > 0,
23+
])
924
/>
1025
@else
11-
<div class="comm:w-10 comm:h-10 comm:rounded-full comm:mt-0.5 "></div>
26+
<div @class([
27+
'comm:rounded-full comm:mt-0.5',
28+
'comm:w-10 comm:h-10' => $depth === 0,
29+
'comm:w-7 comm:h-7' => $depth > 0,
30+
])></div>
1231
@endif
1332

1433
<div class="comm:flex-1">
@@ -155,17 +174,45 @@ class="comm:text-xs comm:text-gray-300 comm:ml-1"
155174
@endif
156175

157176
@if ($comment->isComment() && config('commentions.threading.enabled', false) && $comment->replies->isNotEmpty())
158-
<div class="commentions-replies comm:mt-3 comm:space-y-2 comm:border-l comm:border-gray-200 comm:dark:border-gray-700 comm:pl-3">
159-
@foreach ($comment->replies as $reply)
160-
<livewire:dynamic-component
161-
:component="$commentionsComponentPrefix . 'comment'"
162-
:key="'reply-' . $reply->getContentHash()"
163-
:comment="$reply"
164-
:depth="$depth + 1"
165-
:mentionables="$mentionables"
166-
:tip-tap-css-classes="$tipTapCssClasses"
177+
<div x-data="{ expanded: true }" class="comm:mt-3">
178+
<button
179+
type="button"
180+
@click="expanded = !expanded"
181+
:aria-expanded="expanded ? 'true' : 'false'"
182+
aria-controls="comment-replies-{{ $comment->getId() }}"
183+
class="comm:flex comm:items-center comm:gap-x-1 comm:text-xs comm:font-medium comm:text-gray-500 comm:dark:text-gray-400 comm:hover:text-gray-700 comm:dark:hover:text-gray-200 comm:focus:outline-none comm:focus-visible:ring-2 comm:focus-visible:ring-blue-500 comm:rounded comm:mb-1"
184+
>
185+
<x-filament::icon
186+
icon="heroicon-m-chevron-down"
187+
class="comm:w-4 comm:h-4 comm:transition-transform"
188+
x-bind:class="expanded ? '' : 'comm:-rotate-90'"
167189
/>
168-
@endforeach
190+
<span x-show="expanded">{{ __('commentions::comments.hide_replies') }}</span>
191+
<span x-show="!expanded" x-cloak>{{ trans_choice('commentions::comments.replies_count', $comment->repliesCount(), ['count' => $comment->repliesCount()]) }}</span>
192+
</button>
193+
194+
<div
195+
id="comment-replies-{{ $comment->getId() }}"
196+
role="group"
197+
aria-label="{{ trans_choice('commentions::comments.replies_count', $comment->repliesCount(), ['count' => $comment->repliesCount()]) }}"
198+
x-show="expanded"
199+
x-collapse
200+
@class([
201+
'commentions-replies',
202+
'comm:pl-3' => $this->shouldIndentReplies(),
203+
])
204+
>
205+
@foreach ($comment->replies as $reply)
206+
<livewire:dynamic-component
207+
:component="$commentionsComponentPrefix . 'comment'"
208+
:key="'reply-' . $reply->getContentHash()"
209+
:comment="$reply"
210+
:depth="$depth + 1"
211+
:mentionables="$mentionables"
212+
:tip-tap-css-classes="$tipTapCssClasses"
213+
/>
214+
@endforeach
215+
</div>
169216
</div>
170217
@endif
171218
@endif

src/Comment.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,17 @@ public function depth(): int
196196
return $depth;
197197
}
198198

199+
/**
200+
* Total number of descendant comments across all nested reply levels.
201+
*/
202+
public function repliesCount(): int
203+
{
204+
return $this->replies->reduce(
205+
fn (int $carry, self $reply): int => $carry + 1 + $reply->repliesCount(),
206+
0,
207+
);
208+
}
209+
199210
public function toggleReaction(string $reaction): void
200211
{
201212
ToggleCommentReaction::run($this, $reaction, Config::resolveAuthenticatedUser());

src/Livewire/Comment.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ class Comment extends Component
1717
{
1818
use HasMentions;
1919

20+
/**
21+
* Deepest level that still receives added horizontal indent. Beyond this
22+
* the thread line still renders, but no extra padding is added so deep
23+
* threads don't compound horizontally on small screens.
24+
*/
25+
public const INDENT_CAP_DEPTH = 2;
26+
2027
public CommentModel|RenderableComment $comment;
2128

2229
public string $commentBody = '';
@@ -189,6 +196,15 @@ public function canReply(): bool
189196
&& (bool) Config::resolveAuthenticatedUser()?->can('create', Config::getCommentModel());
190197
}
191198

199+
/**
200+
* Whether the replies wrapper rendered by this comment should add
201+
* horizontal indent. Past the cap the thread line still renders.
202+
*/
203+
public function shouldIndentReplies(): bool
204+
{
205+
return $this->depth < self::INDENT_CAP_DEPTH;
206+
}
207+
192208
protected function maxReplyDepth(): int
193209
{
194210
return max(0, (int) config('commentions.threading.max_depth', 3));

tests/Livewire/CommentThreadingTest.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,102 @@
143143
test()->assertDatabaseMissing('comments', ['id' => $reply->id]);
144144
test()->assertDatabaseMissing('comments', ['id' => $nested->id]);
145145
});
146+
147+
test('a top-level comment renders as a bordered card', function () {
148+
$user = User::factory()->create();
149+
actingAs($user);
150+
151+
$post = Post::factory()->create();
152+
$comment = Comment::factory()->author($user)->commentable($post)->create();
153+
154+
livewire(CommentComponent::class, ['comment' => $comment, 'depth' => 0])
155+
->assertSeeHtml('comm:rounded-lg')
156+
->assertSeeHtml('comm:shadow-sm')
157+
->assertDontSeeHtml('commentions-thread');
158+
});
159+
160+
test('a reply renders as a flat row with a thread connector', function () {
161+
$user = User::factory()->create();
162+
actingAs($user);
163+
164+
$post = Post::factory()->create();
165+
$parent = Comment::factory()->author($user)->commentable($post)->create();
166+
$reply = Comment::factory()->author($user)->commentable($post)->create(['parent_id' => $parent->id]);
167+
168+
livewire(CommentComponent::class, ['comment' => $reply, 'depth' => 1])
169+
->assertDontSeeHtml('comm:shadow-sm')
170+
->assertSeeHtml('comm:py-2')
171+
->assertSeeHtml('comm:w-7')
172+
->assertSeeHtml('class="commentions-thread"');
173+
});
174+
175+
test('a comment with replies renders an accessible collapse toggle', function () {
176+
$user = User::factory()->create();
177+
actingAs($user);
178+
179+
$post = Post::factory()->create();
180+
$parent = Comment::factory()->author($user)->commentable($post)->create();
181+
Comment::factory()->author($user)->commentable($post)->create(['parent_id' => $parent->id]);
182+
183+
livewire(CommentComponent::class, ['comment' => $parent, 'depth' => 0])
184+
->assertSeeHtml(':aria-expanded')
185+
->assertSeeHtml('aria-controls="comment-replies-' . $parent->id . '"')
186+
->assertSeeHtml('id="comment-replies-' . $parent->id . '"')
187+
->assertSeeHtml('role="group"');
188+
});
189+
190+
test('the collapse toggle shows the total descendant reply count', function () {
191+
$user = User::factory()->create();
192+
actingAs($user);
193+
194+
$post = Post::factory()->create();
195+
$parent = Comment::factory()->author($user)->commentable($post)->create();
196+
$child = Comment::factory()->author($user)->commentable($post)->create(['parent_id' => $parent->id]);
197+
Comment::factory()->author($user)->commentable($post)->create(['parent_id' => $child->id]);
198+
199+
livewire(CommentComponent::class, ['comment' => $parent, 'depth' => 0])
200+
->assertSee('2 replies');
201+
});
202+
203+
test('a comment with no replies renders no collapse toggle', function () {
204+
$user = User::factory()->create();
205+
actingAs($user);
206+
207+
$post = Post::factory()->create();
208+
$comment = Comment::factory()->author($user)->commentable($post)->create();
209+
210+
livewire(CommentComponent::class, ['comment' => $comment, 'depth' => 0])
211+
->assertDontSeeHtml('aria-controls="comment-replies-');
212+
});
213+
214+
test('replies indent for the first levels then stop', function () {
215+
config(['commentions.threading.max_depth' => 5]);
216+
217+
$user = User::factory()->create();
218+
actingAs($user);
219+
220+
$post = Post::factory()->create();
221+
$c0 = Comment::factory()->author($user)->commentable($post)->create();
222+
$c1 = Comment::factory()->author($user)->commentable($post)->create(['parent_id' => $c0->id]);
223+
Comment::factory()->author($user)->commentable($post)->create(['parent_id' => $c1->id]);
224+
225+
// A depth-0 wrapper indents the replies it renders.
226+
livewire(CommentComponent::class, ['comment' => $c0, 'depth' => 0])
227+
->assertSeeHtml('comm:pl-3');
228+
229+
// A wrapper at INDENT_CAP_DEPTH stops adding indent.
230+
livewire(CommentComponent::class, ['comment' => $c1, 'depth' => CommentComponent::INDENT_CAP_DEPTH])
231+
->assertDontSeeHtml('comm:pl-3');
232+
});
233+
234+
test('repliesCount counts every descendant comment', function () {
235+
$user = User::factory()->create();
236+
$post = Post::factory()->create();
237+
238+
$root = Comment::factory()->author($user)->commentable($post)->create();
239+
$a = Comment::factory()->author($user)->commentable($post)->create(['parent_id' => $root->id]);
240+
Comment::factory()->author($user)->commentable($post)->create(['parent_id' => $a->id]);
241+
Comment::factory()->author($user)->commentable($post)->create(['parent_id' => $root->id]);
242+
243+
expect($root->fresh()->repliesCount())->toBe(3);
244+
});

0 commit comments

Comments
 (0)