Skip to content

Commit fb46ef4

Browse files
committed
[Documentation] Message Scroller: faithful chat-window demo with Empty state
Add the shadcn-style chat window as the hero example: a Card with an Empty state until the first message, then a scrolling transcript that follows the live edge, plus an input footer. A docs-only Stimulus demo harness (message-scroller-chat) clones server-rendered user/assistant templates on send so the scroller's autoscroll/anchoring is demonstrated live — standing in for a real ActionCable/streaming source. Uses the new Empty component.
1 parent 7d06583 commit fb46ef4

5 files changed

Lines changed: 236 additions & 1 deletion

File tree

docs/app/javascript/controllers/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { application } from "./application"
77
import IframeThemeController from "./iframe_theme_controller"
88
application.register("iframe-theme", IframeThemeController)
99

10+
import MessageScrollerChatController from "./message_scroller_chat_controller"
11+
application.register("message-scroller-chat", MessageScrollerChatController)
12+
1013
import RubyUi__AccordionController from "./ruby_ui/accordion_controller"
1114
application.register("ruby-ui--accordion", RubyUi__AccordionController)
1215

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Controller } from "@hotwired/stimulus";
2+
3+
// Docs-only demo harness for the Message Scroller chat window.
4+
//
5+
// On submit it clones the server-rendered user/assistant <template> rows into
6+
// the scroller content and clears the empty state. The MessageScroller
7+
// controller observes the content and handles autoscroll/anchoring — this
8+
// controller only produces rows, the way a real ActionCable/streaming source
9+
// would. Connects to data-controller="message-scroller-chat".
10+
export default class extends Controller {
11+
static targets = ["content", "empty", "scroller", "input", "userTemplate", "assistantTemplate"];
12+
static values = {
13+
replies: { type: Array, default: [] },
14+
};
15+
16+
connect() {
17+
this.turn = 0;
18+
}
19+
20+
send(event) {
21+
event.preventDefault();
22+
const text = this.hasInputTarget ? this.inputTarget.value.trim() : "";
23+
if (!text) return;
24+
25+
this.reveal();
26+
this.appendRow(this.userTemplateTarget, text);
27+
this.inputTarget.value = "";
28+
29+
const reply = this.repliesValue[this.turn % this.repliesValue.length] || "Got it — thanks!";
30+
this.turn += 1;
31+
// Let the assistant reply land a beat later, like a streamed response.
32+
this.replyTimer = setTimeout(() => {
33+
this.appendRow(this.assistantTemplateTarget, reply);
34+
}, 500);
35+
}
36+
37+
reset() {
38+
if (this.replyTimer) clearTimeout(this.replyTimer);
39+
if (this.hasContentTarget) this.contentTarget.replaceChildren();
40+
this.scrollerTarget?.classList.add("hidden");
41+
this.emptyTarget?.classList.remove("hidden");
42+
this.turn = 0;
43+
}
44+
45+
reveal() {
46+
this.emptyTarget?.classList.add("hidden");
47+
this.scrollerTarget?.classList.remove("hidden");
48+
}
49+
50+
appendRow(template, text) {
51+
if (!template || !this.hasContentTarget) return;
52+
const row = template.content.firstElementChild.cloneNode(true);
53+
const content = row.querySelector("[data-slot=bubble-content]");
54+
if (content) content.textContent = text;
55+
this.contentTarget.appendChild(row);
56+
}
57+
58+
disconnect() {
59+
if (this.replyTimer) clearTimeout(this.replyTimer);
60+
}
61+
}

docs/app/views/docs/message_scroller.rb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,89 @@ def view_template
77
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
88
render Docs::Header.new(title: "Message Scroller", description: "A chat scroll container that anchors turns, follows streamed responses, preserves position when older messages load, and jumps to the latest message.")
99

10+
Heading(level: 2) { "Chat window" }
11+
12+
Text(class: "text-muted-foreground") { "A full chat window: an Empty state until the first message, then a scrolling transcript that follows the live edge. Type and press send — the reply lands a beat later and the view stays pinned to the bottom. Scroll up mid-thread and the jump-to-latest button appears. (The send/reply loop is a docs-only demo harness; in a real app an ActionCable or streaming source would produce the rows.)" }
13+
14+
render Docs::VisualCodeExample.new(title: "Interactive chat", context: self) do
15+
<<~RUBY
16+
div(
17+
class: "mx-auto w-full max-w-sm",
18+
data: {
19+
controller: "message-scroller-chat",
20+
message_scroller_chat_replies_value: [
21+
"Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive.",
22+
"Set scroll_anchor on the turn that should settle near the top, and it keeps a peek of the previous exchange above it.",
23+
"Auto-scroll only runs when you are already at the bottom. Scroll up and your place stays put while new messages arrive below.",
24+
"When there is unseen content the jump-to-latest button appears — one tap returns you to the newest message."
25+
].to_json
26+
}
27+
) do
28+
Card(class: "flex flex-col h-[32rem] gap-0 overflow-hidden") do
29+
CardHeader(class: "gap-1 border-b pb-4") do
30+
CardTitle { "New Chat" }
31+
CardDescription { "How can I help you today?" }
32+
end
33+
CardContent(class: "flex-1 overflow-hidden p-0") do
34+
div(data: {message_scroller_chat_target: "empty"}, class: "h-full") do
35+
Empty(class: "h-full border-none") do
36+
EmptyHeader do
37+
EmptyMedia(variant: :icon) do
38+
svg(xmlns: "http://www.w3.org/2000/svg", fill: "none", viewbox: "0 0 24 24", stroke_width: "1.5", stroke: "currentColor", class: "size-6") do |s|
39+
s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z")
40+
end
41+
end
42+
EmptyTitle { "Morning!" }
43+
EmptyDescription { "What are we working on today? Press send to start a new conversation." }
44+
end
45+
end
46+
end
47+
48+
div(data: {message_scroller_chat_target: "scroller"}, class: "hidden h-full") do
49+
MessageScrollerProvider(auto_scroll: true) do
50+
MessageScroller do
51+
MessageScrollerViewport do
52+
MessageScrollerContent(class: "p-4", data: {message_scroller_chat_target: "content"})
53+
end
54+
MessageScrollerButton()
55+
end
56+
end
57+
end
58+
end
59+
CardFooter(class: "border-t pt-4") do
60+
form(data: {action: "submit->message-scroller-chat#send"}, class: "flex w-full items-center gap-2") do
61+
Input(type: "text", name: "message", placeholder: "Type a message…", autocomplete: "off", class: "flex-1", data: {message_scroller_chat_target: "input"})
62+
Button(type: "submit", class: "shrink-0") do
63+
svg(xmlns: "http://www.w3.org/2000/svg", fill: "none", viewbox: "0 0 24 24", stroke_width: "2", stroke: "currentColor", class: "size-4") do |s|
64+
s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18")
65+
end
66+
span(class: "sr-only") { "Send" }
67+
end
68+
end
69+
end
70+
end
71+
72+
template(data: {message_scroller_chat_target: "userTemplate"}) do
73+
MessageScrollerItem(scroll_anchor: true) do
74+
Message(align: :end) do
75+
MessageAvatar { Avatar(size: :sm) { AvatarFallback { "ME" } } }
76+
MessageContent { Bubble { BubbleContent { "" } } }
77+
end
78+
end
79+
end
80+
81+
template(data: {message_scroller_chat_target: "assistantTemplate"}) do
82+
MessageScrollerItem do
83+
Message do
84+
MessageAvatar { Avatar(size: :sm) { AvatarFallback { "AI" } } }
85+
MessageContent { Bubble(variant: :muted) { BubbleContent { "" } } }
86+
end
87+
end
88+
end
89+
end
90+
RUBY
91+
end
92+
1093
Heading(level: 2) { "Usage" }
1194

1295
Text(class: "text-muted-foreground") { "MessageScroller fills its parent, so place it inside a height-constrained container. It follows the live edge while you are pinned to the bottom and releases the moment you scroll up. Scroll up in the panel below — a jump-to-latest button appears." }

gem/lib/ruby_ui/message_scroller/message_scroller_docs.rb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,89 @@ def view_template
77
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
88
render Docs::Header.new(title: "Message Scroller", description: "A chat scroll container that anchors turns, follows streamed responses, preserves position when older messages load, and jumps to the latest message.")
99

10+
Heading(level: 2) { "Chat window" }
11+
12+
Text(class: "text-muted-foreground") { "A full chat window: an Empty state until the first message, then a scrolling transcript that follows the live edge. Type and press send — the reply lands a beat later and the view stays pinned to the bottom. Scroll up mid-thread and the jump-to-latest button appears. (The send/reply loop is a docs-only demo harness; in a real app an ActionCable or streaming source would produce the rows.)" }
13+
14+
render Docs::VisualCodeExample.new(title: "Interactive chat", context: self) do
15+
<<~RUBY
16+
div(
17+
class: "mx-auto w-full max-w-sm",
18+
data: {
19+
controller: "message-scroller-chat",
20+
message_scroller_chat_replies_value: [
21+
"Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive.",
22+
"Set scroll_anchor on the turn that should settle near the top, and it keeps a peek of the previous exchange above it.",
23+
"Auto-scroll only runs when you are already at the bottom. Scroll up and your place stays put while new messages arrive below.",
24+
"When there is unseen content the jump-to-latest button appears — one tap returns you to the newest message."
25+
].to_json
26+
}
27+
) do
28+
Card(class: "flex flex-col h-[32rem] gap-0 overflow-hidden") do
29+
CardHeader(class: "gap-1 border-b pb-4") do
30+
CardTitle { "New Chat" }
31+
CardDescription { "How can I help you today?" }
32+
end
33+
CardContent(class: "flex-1 overflow-hidden p-0") do
34+
div(data: {message_scroller_chat_target: "empty"}, class: "h-full") do
35+
Empty(class: "h-full border-none") do
36+
EmptyHeader do
37+
EmptyMedia(variant: :icon) do
38+
svg(xmlns: "http://www.w3.org/2000/svg", fill: "none", viewbox: "0 0 24 24", stroke_width: "1.5", stroke: "currentColor", class: "size-6") do |s|
39+
s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z")
40+
end
41+
end
42+
EmptyTitle { "Morning!" }
43+
EmptyDescription { "What are we working on today? Press send to start a new conversation." }
44+
end
45+
end
46+
end
47+
48+
div(data: {message_scroller_chat_target: "scroller"}, class: "hidden h-full") do
49+
MessageScrollerProvider(auto_scroll: true) do
50+
MessageScroller do
51+
MessageScrollerViewport do
52+
MessageScrollerContent(class: "p-4", data: {message_scroller_chat_target: "content"})
53+
end
54+
MessageScrollerButton()
55+
end
56+
end
57+
end
58+
end
59+
CardFooter(class: "border-t pt-4") do
60+
form(data: {action: "submit->message-scroller-chat#send"}, class: "flex w-full items-center gap-2") do
61+
Input(type: "text", name: "message", placeholder: "Type a message…", autocomplete: "off", class: "flex-1", data: {message_scroller_chat_target: "input"})
62+
Button(type: "submit", class: "shrink-0") do
63+
svg(xmlns: "http://www.w3.org/2000/svg", fill: "none", viewbox: "0 0 24 24", stroke_width: "2", stroke: "currentColor", class: "size-4") do |s|
64+
s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18")
65+
end
66+
span(class: "sr-only") { "Send" }
67+
end
68+
end
69+
end
70+
end
71+
72+
template(data: {message_scroller_chat_target: "userTemplate"}) do
73+
MessageScrollerItem(scroll_anchor: true) do
74+
Message(align: :end) do
75+
MessageAvatar { Avatar(size: :sm) { AvatarFallback { "ME" } } }
76+
MessageContent { Bubble { BubbleContent { "" } } }
77+
end
78+
end
79+
end
80+
81+
template(data: {message_scroller_chat_target: "assistantTemplate"}) do
82+
MessageScrollerItem do
83+
Message do
84+
MessageAvatar { Avatar(size: :sm) { AvatarFallback { "AI" } } }
85+
MessageContent { Bubble(variant: :muted) { BubbleContent { "" } } }
86+
end
87+
end
88+
end
89+
end
90+
RUBY
91+
end
92+
1093
Heading(level: 2) { "Usage" }
1194

1295
Text(class: "text-muted-foreground") { "MessageScroller fills its parent, so place it inside a height-constrained container. It follows the live edge while you are pinned to the bottom and releases the moment you scroll up. Scroll up in the panel below — a jump-to-latest button appears." }

0 commit comments

Comments
 (0)