Skip to content

Commit 2cf848c

Browse files
committed
[Feature] Add Message Scroller component
Port the shadcn Message Scroller: a chat transcript scroller that follows the live edge, anchors new turns near the top, and jumps to the latest message. Built on top of Message (#446) and Bubble (#445). shadcn delegates to a closed React primitive (@shadcn/react); this is a from-scratch Stimulus controller (ruby-ui--message-scroller) — our own code, no external lib: - autoScroll follow-edge: pins to the bottom while the reader is there, releases on wheel/touch/keyboard/scrollbar away, re-engages on jump. - scrollAnchor: settles an appended turn near the top keeping a peek of the previous item (previous_item_peek). - defaultPosition: open at end / start / last-anchor. - preserveOnPrepend: hold the visible row when history loads in above. - Public API for streaming/ActionCable: scrollToEnd/scrollToStart/ scrollToMessage; new rows are picked up via MutationObserver. - rAF-based smooth scrolling (native smooth is unreliable on a contained viewport), honors prefers-reduced-motion. - a11y: content role=log + aria-relevant, button sr-only label, button removed from tab order while inert. Parts: MessageScrollerProvider, MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton. dependencies.yml: message_scroller depends on Message + Bubble. MCP registry, docs page, route, controller, menu and site_files updated.
1 parent c9dd5e7 commit 2cf848c

21 files changed

Lines changed: 1189 additions & 3 deletions

docs/app/components/shared/components_list.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def components
3636
{name: "Link", path: docs_link_path},
3737
{name: "Masked Input", path: masked_input_path},
3838
{name: "Message", path: docs_message_path},
39+
{name: "Message Scroller", path: docs_message_scroller_path},
3940
{name: "Pagination", path: docs_pagination_path},
4041
{name: "Popover", path: docs_popover_path},
4142
{name: "Progress", path: docs_progress_path},

docs/app/controllers/docs_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ def message
170170
render Views::Docs::Message.new
171171
end
172172

173+
def message_scroller
174+
render Views::Docs::MessageScroller.new
175+
end
176+
173177
def pagination
174178
render Views::Docs::Pagination.new
175179
end

docs/app/javascript/controllers/index.js

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

10-
import ToastDemoController from "./toast_demo_controller"
11-
application.register("toast-demo", ToastDemoController)
12-
1310
import RubyUi__AccordionController from "./ruby_ui/accordion_controller"
1411
application.register("ruby-ui--accordion", RubyUi__AccordionController)
1512

@@ -76,6 +73,9 @@ application.register("ruby-ui--hover-card", RubyUi__HoverCardController)
7673
import RubyUi__MaskedInputController from "./ruby_ui/masked_input_controller"
7774
application.register("ruby-ui--masked-input", RubyUi__MaskedInputController)
7875

76+
import RubyUi__MessageScrollerController from "./ruby_ui/message_scroller_controller"
77+
application.register("ruby-ui--message-scroller", RubyUi__MessageScrollerController)
78+
7979
import RubyUi__PopoverController from "./ruby_ui/popover_controller"
8080
application.register("ruby-ui--popover", RubyUi__PopoverController)
8181

@@ -117,3 +117,6 @@ application.register("ruby-ui--tooltip", RubyUi__TooltipController)
117117

118118
import SidebarMenuController from "./sidebar_menu_controller"
119119
application.register("sidebar-menu", SidebarMenuController)
120+
121+
import ToastDemoController from "./toast_demo_controller"
122+
application.register("toast-demo", ToastDemoController)
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import { Controller } from "@hotwired/stimulus";
2+
3+
// Connects to data-controller="ruby-ui--message-scroller"
4+
//
5+
// A chat transcript scroller. Owns scroll state and behavior for a
6+
// height-constrained message list:
7+
//
8+
// - autoScroll: follows the live edge while the reader is pinned to the
9+
// bottom, and releases the moment they scroll, wheel, drag, or key away.
10+
// - scrollAnchor: when a new anchored turn is appended, settles it near the
11+
// top of the viewport keeping a peek of the previous turn above it.
12+
// - defaultScrollPosition: where a freshly mounted transcript opens
13+
// ("end", "start" or "last-anchor").
14+
// - preserveScrollOnPrepend: keeps the visible row fixed when older messages
15+
// are loaded in above the current view.
16+
//
17+
// Public API (callable from other controllers/outlets or future
18+
// streaming/ActionCable code): scrollToEnd(), scrollToStart(),
19+
// scrollToMessage(id). New rows appended to the content target are picked up
20+
// automatically via MutationObserver — no manual call needed.
21+
export default class extends Controller {
22+
static targets = ["viewport", "content", "button"];
23+
24+
static values = {
25+
autoScroll: { type: Boolean, default: true },
26+
previousItemPeek: { type: Number, default: 64 },
27+
defaultPosition: { type: String, default: "end" },
28+
preserveOnPrepend: { type: Boolean, default: true },
29+
endThreshold: { type: Number, default: 32 },
30+
};
31+
32+
connect() {
33+
// Reader is considered "following" the live edge until they move away.
34+
this.following = true;
35+
// True only while a programmatic scroll is in flight, so reader-intent
36+
// handlers don't mistake our own scrolling for the reader's.
37+
this.programmatic = false;
38+
39+
this.onScroll = this.onScroll.bind(this);
40+
this.onWheel = this.onWheel.bind(this);
41+
this.onTouchStart = this.onTouchStart.bind(this);
42+
this.onKeydown = this.onKeydown.bind(this);
43+
44+
if (this.hasViewportTarget) {
45+
this.viewportTarget.addEventListener("scroll", this.onScroll, { passive: true });
46+
this.viewportTarget.addEventListener("wheel", this.onWheel, { passive: true });
47+
this.viewportTarget.addEventListener("touchstart", this.onTouchStart, { passive: true });
48+
this.viewportTarget.addEventListener("keydown", this.onKeydown);
49+
}
50+
51+
if (this.hasContentTarget) {
52+
// Announce streamed/added messages to assistive tech at a calm pace.
53+
if (!this.contentTarget.hasAttribute("role")) {
54+
this.contentTarget.setAttribute("role", "log");
55+
}
56+
if (!this.contentTarget.hasAttribute("aria-relevant")) {
57+
this.contentTarget.setAttribute("aria-relevant", "additions text");
58+
}
59+
60+
this.observer = new MutationObserver((records) => this.onMutations(records));
61+
this.observer.observe(this.contentTarget, {
62+
childList: true,
63+
subtree: true,
64+
characterData: true,
65+
});
66+
}
67+
68+
// Apply the opening position after layout settles.
69+
requestAnimationFrame(() => {
70+
this.applyDefaultPosition();
71+
this.updateButton();
72+
});
73+
}
74+
75+
disconnect() {
76+
if (this.hasViewportTarget) {
77+
this.viewportTarget.removeEventListener("scroll", this.onScroll);
78+
this.viewportTarget.removeEventListener("wheel", this.onWheel);
79+
this.viewportTarget.removeEventListener("touchstart", this.onTouchStart);
80+
this.viewportTarget.removeEventListener("keydown", this.onKeydown);
81+
}
82+
this.observer?.disconnect();
83+
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
84+
}
85+
86+
// --- Reader intent -------------------------------------------------------
87+
88+
onScroll() {
89+
if (this.programmatic) return;
90+
this.following = this.isAtEnd();
91+
this.updateButton();
92+
}
93+
94+
// Any upward wheel is a deliberate move away from the live edge.
95+
onWheel(event) {
96+
if (event.deltaY < 0) this.release();
97+
}
98+
99+
onTouchStart() {
100+
// A touch that turns into an upward drag surfaces through onScroll; this
101+
// just makes the release feel immediate when the reader grabs the list.
102+
if (!this.isAtEnd()) this.release();
103+
}
104+
105+
onKeydown(event) {
106+
const navKeys = ["ArrowUp", "PageUp", "Home", "ArrowDown", "PageDown", "End", " "];
107+
if (navKeys.includes(event.key)) this.release();
108+
}
109+
110+
release() {
111+
if (this.programmatic) return;
112+
this.following = false;
113+
}
114+
115+
// --- Mutations (new / prepended / streamed rows) -------------------------
116+
117+
onMutations(records) {
118+
let appended = null;
119+
let prependedHeight = 0;
120+
121+
for (const record of records) {
122+
if (record.type !== "childList") continue;
123+
for (const node of record.addedNodes) {
124+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
125+
if (record.previousSibling === null && record.nextSibling !== null) {
126+
// Inserted above existing rows → history prepend.
127+
prependedHeight += node.offsetHeight || 0;
128+
} else {
129+
// Inserted at (or after) the end → new turn.
130+
appended = node;
131+
}
132+
}
133+
}
134+
135+
if (prependedHeight > 0 && this.preserveOnPrependValue) {
136+
// Keep the reader's current row fixed while history loads in above.
137+
this.viewportTarget.scrollTop += prependedHeight;
138+
}
139+
140+
if (appended) {
141+
const anchor = appended.matches?.("[data-scroll-anchor]")
142+
? appended
143+
: appended.querySelector?.("[data-scroll-anchor]");
144+
if (anchor) {
145+
this.scrollToAnchor(anchor);
146+
} else if (this.autoScrollValue && this.following) {
147+
this.scrollToEnd();
148+
}
149+
} else if (this.autoScrollValue && this.following) {
150+
// No new row — text streamed into the last row. Stay pinned.
151+
this.scrollToEnd("auto");
152+
}
153+
154+
this.updateButton();
155+
}
156+
157+
// --- Public scroll commands ---------------------------------------------
158+
159+
scrollToEnd(behavior = "smooth") {
160+
if (!this.hasViewportTarget) return;
161+
this.following = true;
162+
this.scrollTo(this.viewportTarget.scrollHeight, behavior);
163+
}
164+
165+
scrollToStart(behavior = "smooth") {
166+
if (!this.hasViewportTarget) return;
167+
this.following = false;
168+
this.scrollTo(0, behavior);
169+
}
170+
171+
// Scroll a row with a matching messageId into view. Returns false when the
172+
// target is not mounted.
173+
scrollToMessage(id, behavior = "smooth") {
174+
if (!this.hasContentTarget) return false;
175+
const item = this.contentTarget.querySelector(`[data-message-id="${CSS.escape(id)}"]`);
176+
if (!item) return false;
177+
this.following = false;
178+
this.scrollToAnchor(item, behavior);
179+
return true;
180+
}
181+
182+
scrollToAnchor(item, behavior = "smooth") {
183+
const top = Math.max(0, item.offsetTop - this.previousItemPeekValue);
184+
this.scrollTo(top, behavior);
185+
}
186+
187+
// Bound to the scroll button's click action.
188+
jumpToEnd() {
189+
this.scrollToEnd();
190+
}
191+
192+
// --- Internals -----------------------------------------------------------
193+
194+
// Native scrollTo({ behavior: "smooth" }) is unreliable on a contained,
195+
// virtualized viewport, so we animate scrollTop ourselves with rAF. This
196+
// gives us full control over completion (no scrollend dependency) and lets
197+
// us honor reduced-motion.
198+
scrollTo(top, behavior = "smooth") {
199+
if (!this.hasViewportTarget) return;
200+
const max = this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight;
201+
const target = Math.max(0, Math.min(top, max));
202+
203+
this.programmatic = true;
204+
this.element.setAttribute("data-autoscrolling", "");
205+
this.viewportTarget.setAttribute("data-autoscrolling", "");
206+
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
207+
208+
if (behavior === "auto" || this.prefersReducedMotion()) {
209+
this.viewportTarget.scrollTop = target;
210+
this.finishScroll();
211+
return;
212+
}
213+
214+
const start = this.viewportTarget.scrollTop;
215+
const distance = target - start;
216+
const duration = 300;
217+
let startTime = null;
218+
219+
const step = (now) => {
220+
if (startTime === null) startTime = now;
221+
const t = Math.min(1, (now - startTime) / duration);
222+
// easeOutCubic
223+
const eased = 1 - Math.pow(1 - t, 3);
224+
this.viewportTarget.scrollTop = start + distance * eased;
225+
if (t < 1) {
226+
this.animationFrame = requestAnimationFrame(step);
227+
} else {
228+
this.finishScroll();
229+
}
230+
};
231+
this.animationFrame = requestAnimationFrame(step);
232+
}
233+
234+
finishScroll() {
235+
this.programmatic = false;
236+
this.element.removeAttribute("data-autoscrolling");
237+
this.viewportTarget?.removeAttribute("data-autoscrolling");
238+
this.following = this.isAtEnd();
239+
this.updateButton();
240+
}
241+
242+
prefersReducedMotion() {
243+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
244+
}
245+
246+
applyDefaultPosition() {
247+
if (!this.hasViewportTarget) return;
248+
const position = this.defaultPositionValue;
249+
250+
if (position === "start") {
251+
this.following = false;
252+
this.viewportTarget.scrollTop = 0;
253+
return;
254+
}
255+
256+
if (position === "last-anchor") {
257+
const anchors = this.contentTarget?.querySelectorAll("[data-scroll-anchor]");
258+
const last = anchors && anchors[anchors.length - 1];
259+
// Fall back to the end when there's no anchor, or the last turn already
260+
// fits in the viewport.
261+
if (last && last.offsetTop - this.previousItemPeekValue > 0) {
262+
this.following = false;
263+
this.viewportTarget.scrollTop = Math.max(0, last.offsetTop - this.previousItemPeekValue);
264+
this.updateButton();
265+
return;
266+
}
267+
}
268+
269+
// Default: open at the live edge.
270+
this.following = true;
271+
this.viewportTarget.scrollTop = this.viewportTarget.scrollHeight;
272+
}
273+
274+
isAtEnd() {
275+
if (!this.hasViewportTarget) return true;
276+
const { scrollTop, clientHeight, scrollHeight } = this.viewportTarget;
277+
return scrollHeight - (scrollTop + clientHeight) <= this.endThresholdValue;
278+
}
279+
280+
hasOverflow() {
281+
if (!this.hasViewportTarget) return false;
282+
return this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight > this.endThresholdValue;
283+
}
284+
285+
updateButton() {
286+
if (!this.hasButtonTarget) return;
287+
const active = this.hasOverflow() && !this.isAtEnd();
288+
this.buttonTarget.setAttribute("data-active", active ? "true" : "false");
289+
// Remove the inert button from the tab order so there are no ghost stops.
290+
this.buttonTarget.setAttribute("tabindex", active ? "0" : "-1");
291+
}
292+
}

docs/app/lib/site_files.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ class SiteFiles
109109
{title: "Link", path: "/docs/link", description: "Link component with button-like and underline variants."},
110110
{title: "Masked Input", path: "/docs/masked_input", description: "Form input with an applied mask."},
111111
{title: "Message", path: "/docs/message", description: "Chat message layout pairing an avatar with bubbles, headers, and footers."},
112+
{title: "Message Scroller", path: "/docs/message_scroller", description: "Chat scroll container that anchors turns, follows streamed output, and jumps to the latest message."},
112113
{title: "Pagination", path: "/docs/pagination", description: "Page navigation with next and previous links."},
113114
{title: "Popover", path: "/docs/popover", description: "Triggered rich content panel."},
114115
{title: "Progress", path: "/docs/progress", description: "Progress bar for task completion state."},

0 commit comments

Comments
 (0)