ruby_ui 1.3.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -0
  3. data/lib/generators/ruby_ui/component/all_generator.rb +6 -4
  4. data/lib/generators/ruby_ui/component_generator.rb +51 -31
  5. data/lib/generators/ruby_ui/dependencies.yml +12 -2
  6. data/lib/generators/ruby_ui/javascript_utils.rb +0 -13
  7. data/lib/ruby_ui/accordion/accordion_content.rb +4 -2
  8. data/lib/ruby_ui/accordion/accordion_controller.js +28 -6
  9. data/lib/ruby_ui/avatar/avatar_image.rb +5 -1
  10. data/lib/ruby_ui/bubble/bubble.rb +37 -0
  11. data/lib/ruby_ui/bubble/bubble_content.rb +23 -0
  12. data/lib/ruby_ui/bubble/bubble_docs.rb +167 -0
  13. data/lib/ruby_ui/bubble/bubble_group.rb +18 -0
  14. data/lib/ruby_ui/bubble/bubble_reactions.rb +38 -0
  15. data/lib/ruby_ui/combobox/combobox.rb +3 -1
  16. data/lib/ruby_ui/combobox/combobox_checkbox.rb +8 -1
  17. data/lib/ruby_ui/combobox/combobox_controller.js +3 -2
  18. data/lib/ruby_ui/combobox/combobox_trigger.rb +1 -4
  19. data/lib/ruby_ui/context_menu/context_menu_content.rb +5 -6
  20. data/lib/ruby_ui/context_menu/context_menu_controller.js +87 -75
  21. data/lib/ruby_ui/data_table/data_table_column_toggle.rb +8 -4
  22. data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +12 -2
  23. data/lib/ruby_ui/data_table/data_table_docs.rb +1 -1
  24. data/lib/ruby_ui/dialog/dialog_content.rb +7 -19
  25. data/lib/ruby_ui/dialog/dialog_controller.js +22 -10
  26. data/lib/ruby_ui/dropdown_menu/dropdown_menu.rb +0 -1
  27. data/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js +5 -1
  28. data/lib/ruby_ui/dropdown_menu/dropdown_menu_docs.rb +1 -1
  29. data/lib/ruby_ui/dropdown_menu/dropdown_menu_item.rb +10 -4
  30. data/lib/ruby_ui/empty/empty.rb +18 -0
  31. data/lib/ruby_ui/empty/empty_content.rb +18 -0
  32. data/lib/ruby_ui/empty/empty_description.rb +18 -0
  33. data/lib/ruby_ui/empty/empty_docs.rb +69 -0
  34. data/lib/ruby_ui/empty/empty_header.rb +18 -0
  35. data/lib/ruby_ui/empty/empty_media.rb +31 -0
  36. data/lib/ruby_ui/empty/empty_title.rb +18 -0
  37. data/lib/ruby_ui/form/form_docs.rb +89 -0
  38. data/lib/ruby_ui/hover_card/hover_card_content.rb +5 -6
  39. data/lib/ruby_ui/hover_card/hover_card_controller.js +113 -76
  40. data/lib/ruby_ui/input_otp/input_otp.rb +42 -0
  41. data/lib/ruby_ui/input_otp/input_otp_controller.js +128 -0
  42. data/lib/ruby_ui/input_otp/input_otp_docs.rb +213 -0
  43. data/lib/ruby_ui/input_otp/input_otp_group.rb +15 -0
  44. data/lib/ruby_ui/input_otp/input_otp_separator.rb +39 -0
  45. data/lib/ruby_ui/input_otp/input_otp_slot.rb +33 -0
  46. data/lib/ruby_ui/message/message.rb +23 -0
  47. data/lib/ruby_ui/message/message_avatar.rb +18 -0
  48. data/lib/ruby_ui/message/message_content.rb +18 -0
  49. data/lib/ruby_ui/message/message_docs.rb +173 -0
  50. data/lib/ruby_ui/message/message_footer.rb +18 -0
  51. data/lib/ruby_ui/message/message_group.rb +18 -0
  52. data/lib/ruby_ui/message/message_header.rb +18 -0
  53. data/lib/ruby_ui/message_scroller/message_scroller.rb +18 -0
  54. data/lib/ruby_ui/message_scroller/message_scroller_button.rb +56 -0
  55. data/lib/ruby_ui/message_scroller/message_scroller_content.rb +23 -0
  56. data/lib/ruby_ui/message_scroller/message_scroller_controller.js +335 -0
  57. data/lib/ruby_ui/message_scroller/message_scroller_docs.rb +208 -0
  58. data/lib/ruby_ui/message_scroller/message_scroller_item.rb +28 -0
  59. data/lib/ruby_ui/message_scroller/message_scroller_provider.rb +34 -0
  60. data/lib/ruby_ui/message_scroller/message_scroller_viewport.rb +22 -0
  61. data/lib/ruby_ui/native_select/native_select_icon.rb +1 -1
  62. data/lib/ruby_ui/popover/popover_content.rb +2 -1
  63. data/lib/ruby_ui/popover/popover_controller.js +95 -32
  64. data/lib/ruby_ui/table/table_docs.rb +2 -2
  65. data/lib/ruby_ui/tabs/tabs_docs.rb +1 -1
  66. data/lib/ruby_ui/tabs/tabs_trigger.rb +10 -4
  67. data/lib/ruby_ui/toast/toaster_controller.js +24 -8
  68. data/lib/ruby_ui.rb +1 -1
  69. metadata +35 -2
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class MessageScrollerContent < Base
5
+ def view_template(&)
6
+ div(**attrs, &)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ role: "log",
14
+ aria_relevant: "additions text",
15
+ data: {
16
+ slot: "message-scroller-content",
17
+ ruby_ui__message_scroller_target: "content"
18
+ },
19
+ class: "flex h-max min-h-full flex-col gap-8"
20
+ }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,335 @@
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
+ let streamed = false;
121
+ const gap = this.rowGap();
122
+
123
+ for (const record of records) {
124
+ // Text streamed into an existing row (e.g. tokens) — not a new turn.
125
+ if (record.type === "characterData") {
126
+ streamed = true;
127
+ continue;
128
+ }
129
+ if (record.type !== "childList") continue;
130
+ // Only direct children of the content element are transcript rows.
131
+ // Markup inserted *inside* a message must not be mistaken for history.
132
+ if (record.target !== this.contentTarget) {
133
+ streamed = true;
134
+ continue;
135
+ }
136
+ for (const node of record.addedNodes) {
137
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
138
+ if (record.previousSibling === null && record.nextSibling !== null) {
139
+ // Inserted above existing rows → history prepend. Account for the
140
+ // flex row gap each prepended row introduces, or the preserved row
141
+ // drifts down by one gap per insertion.
142
+ prependedHeight += (node.offsetHeight || 0) + gap;
143
+ } else {
144
+ // Inserted at (or after) the end → new turn.
145
+ appended = node;
146
+ }
147
+ }
148
+ }
149
+
150
+ if (prependedHeight > 0 && this.preserveOnPrependValue) {
151
+ // Keep the reader's current row fixed while history loads in above.
152
+ this.viewportTarget.scrollTop += prependedHeight;
153
+ }
154
+
155
+ // Only move for new/streamed content while the reader is at the live edge.
156
+ // If they scrolled away, leave them there and let the button surface it.
157
+ const follow = this.autoScrollValue && this.following;
158
+ if (appended && follow) {
159
+ const anchor = appended.matches?.("[data-scroll-anchor]")
160
+ ? appended
161
+ : appended.querySelector?.("[data-scroll-anchor]");
162
+ if (anchor) {
163
+ this.scrollToAnchor(anchor);
164
+ } else {
165
+ this.scrollToEnd();
166
+ }
167
+ } else if (!appended && streamed && follow) {
168
+ // Text streamed into the last row. Stay pinned.
169
+ this.scrollToEnd("auto");
170
+ }
171
+
172
+ this.updateButton();
173
+ }
174
+
175
+ rowGap() {
176
+ if (!this.hasContentTarget) return 0;
177
+ const value = parseFloat(getComputedStyle(this.contentTarget).rowGap);
178
+ return Number.isFinite(value) ? value : 0;
179
+ }
180
+
181
+ // --- Public scroll commands ---------------------------------------------
182
+
183
+ scrollToEnd(behavior = "smooth") {
184
+ if (!this.hasViewportTarget) return;
185
+ this.following = true;
186
+ this.scrollTo(this.viewportTarget.scrollHeight, behavior);
187
+ }
188
+
189
+ scrollToStart(behavior = "smooth") {
190
+ if (!this.hasViewportTarget) return;
191
+ this.following = false;
192
+ this.scrollTo(0, behavior);
193
+ }
194
+
195
+ // Scroll a row with a matching messageId into view. Returns false when the
196
+ // target is not mounted.
197
+ scrollToMessage(id, behavior = "smooth") {
198
+ if (!this.hasContentTarget) return false;
199
+ const item = this.contentTarget.querySelector(`[data-message-id="${CSS.escape(id)}"]`);
200
+ if (!item) return false;
201
+ this.following = false;
202
+ this.scrollToAnchor(item, behavior);
203
+ return true;
204
+ }
205
+
206
+ scrollToAnchor(item, behavior = "smooth") {
207
+ const top = Math.max(0, item.offsetTop - this.previousItemPeekValue);
208
+ this.scrollTo(top, behavior);
209
+ }
210
+
211
+ // Bound to the scroll button's click action. Honors the button's
212
+ // data-direction so a start-direction button jumps to the start.
213
+ jump(event) {
214
+ if (event?.currentTarget?.dataset.direction === "start") {
215
+ this.scrollToStart();
216
+ } else {
217
+ this.scrollToEnd();
218
+ }
219
+ }
220
+
221
+ // --- Internals -----------------------------------------------------------
222
+
223
+ // Native scrollTo({ behavior: "smooth" }) is unreliable on a contained,
224
+ // virtualized viewport, so we animate scrollTop ourselves with rAF. This
225
+ // gives us full control over completion (no scrollend dependency) and lets
226
+ // us honor reduced-motion.
227
+ scrollTo(top, behavior = "smooth") {
228
+ if (!this.hasViewportTarget) return;
229
+ const max = this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight;
230
+ const target = Math.max(0, Math.min(top, max));
231
+
232
+ this.programmatic = true;
233
+ this.element.setAttribute("data-autoscrolling", "");
234
+ this.viewportTarget.setAttribute("data-autoscrolling", "");
235
+ if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
236
+
237
+ if (behavior === "auto" || this.prefersReducedMotion()) {
238
+ this.viewportTarget.scrollTop = target;
239
+ this.finishScroll();
240
+ return;
241
+ }
242
+
243
+ const start = this.viewportTarget.scrollTop;
244
+ const distance = target - start;
245
+ const duration = 300;
246
+ let startTime = null;
247
+
248
+ const step = (now) => {
249
+ if (startTime === null) startTime = now;
250
+ const t = Math.min(1, (now - startTime) / duration);
251
+ // easeOutCubic
252
+ const eased = 1 - Math.pow(1 - t, 3);
253
+ this.viewportTarget.scrollTop = start + distance * eased;
254
+ if (t < 1) {
255
+ this.animationFrame = requestAnimationFrame(step);
256
+ } else {
257
+ this.finishScroll();
258
+ }
259
+ };
260
+ this.animationFrame = requestAnimationFrame(step);
261
+ }
262
+
263
+ finishScroll() {
264
+ this.programmatic = false;
265
+ this.element.removeAttribute("data-autoscrolling");
266
+ this.viewportTarget?.removeAttribute("data-autoscrolling");
267
+ this.following = this.isAtEnd();
268
+ this.updateButton();
269
+ }
270
+
271
+ prefersReducedMotion() {
272
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
273
+ }
274
+
275
+ applyDefaultPosition() {
276
+ if (!this.hasViewportTarget) return;
277
+ const position = this.defaultPositionValue;
278
+
279
+ if (position === "start") {
280
+ this.following = false;
281
+ this.viewportTarget.scrollTop = 0;
282
+ return;
283
+ }
284
+
285
+ if (position === "last-anchor") {
286
+ // Stimulus' contentTarget getter throws when missing — guard explicitly.
287
+ const anchors = this.hasContentTarget
288
+ ? this.contentTarget.querySelectorAll("[data-scroll-anchor]")
289
+ : [];
290
+ const last = anchors[anchors.length - 1];
291
+ // Fall back to the end when there's no anchor, or the last turn already
292
+ // fits in the viewport.
293
+ if (last && last.offsetTop - this.previousItemPeekValue > 0) {
294
+ this.following = false;
295
+ this.viewportTarget.scrollTop = Math.max(0, last.offsetTop - this.previousItemPeekValue);
296
+ this.updateButton();
297
+ return;
298
+ }
299
+ }
300
+
301
+ // Default: open at the live edge.
302
+ this.following = true;
303
+ this.viewportTarget.scrollTop = this.viewportTarget.scrollHeight;
304
+ }
305
+
306
+ isAtEnd() {
307
+ if (!this.hasViewportTarget) return true;
308
+ const { scrollTop, clientHeight, scrollHeight } = this.viewportTarget;
309
+ return scrollHeight - (scrollTop + clientHeight) <= this.endThresholdValue;
310
+ }
311
+
312
+ isAtStart() {
313
+ if (!this.hasViewportTarget) return true;
314
+ return this.viewportTarget.scrollTop <= this.endThresholdValue;
315
+ }
316
+
317
+ hasOverflow() {
318
+ if (!this.hasViewportTarget) return false;
319
+ return this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight > this.endThresholdValue;
320
+ }
321
+
322
+ // Each button activates based on its own direction: an end button when the
323
+ // reader is away from the bottom, a start button when away from the top.
324
+ updateButton() {
325
+ if (!this.hasButtonTarget) return;
326
+ const overflow = this.hasOverflow();
327
+ this.buttonTargets.forEach((button) => {
328
+ const toStart = button.dataset.direction === "start";
329
+ const active = overflow && (toStart ? !this.isAtStart() : !this.isAtEnd());
330
+ button.setAttribute("data-active", active ? "true" : "false");
331
+ // Remove the inert button from the tab order so there are no ghost stops.
332
+ button.setAttribute("tabindex", active ? "0" : "-1");
333
+ });
334
+ }
335
+ }
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Views::Docs::MessageScroller < Views::Base
4
+ def view_template
5
+ component = "MessageScroller"
6
+
7
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
8
+ 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.")
9
+
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
+
93
+ Heading(level: 2) { "Usage" }
94
+
95
+ 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." }
96
+
97
+ render Docs::VisualCodeExample.new(title: "Streaming chat", context: self) do
98
+ <<~RUBY
99
+ turns = [
100
+ {role: :user, name: "ME", text: "The scroll behavior in my chat is driving me nuts. Every time the AI streams a reply, the whole thread jumps around."},
101
+ {role: :assistant, name: "AI", text: "Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive, so the latest text lands in place."},
102
+ {role: :user, name: "ME", text: "But when someone sends a new message the view feels jarring, like the conversation reloads from the top."},
103
+ {role: :assistant, name: "AI", text: "MessageScrollerItem fixes that with turn anchoring. Set scrollAnchor on the turn that should settle near the top, and it leaves a peek of the previous exchange above it."},
104
+ {role: :user, name: "ME", text: "And if they scrolled up to re-read an older answer?"},
105
+ {role: :assistant, name: "AI", text: "You won't yank them back. Auto-scroll only runs when the viewport is already at the bottom. When there is unseen content, the scroll button appears — one tap returns to the newest message."}
106
+ ]
107
+
108
+ MessageScrollerProvider(auto_scroll: true) do
109
+ div(class: "h-96 w-full rounded-xl border bg-background") do
110
+ MessageScroller do
111
+ MessageScrollerViewport do
112
+ MessageScrollerContent(class: "p-4") do
113
+ turns.each do |turn|
114
+ MessageScrollerItem(scroll_anchor: turn[:role] == :user) do
115
+ Message(align: turn[:role] == :user ? :end : :start) do
116
+ MessageAvatar do
117
+ Avatar(size: :sm) { AvatarFallback { turn[:name] } }
118
+ end
119
+ MessageContent do
120
+ Bubble(variant: turn[:role] == :user ? :default : :muted) do
121
+ BubbleContent { turn[:text] }
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ MessageScrollerButton()
130
+ end
131
+ end
132
+ end
133
+ RUBY
134
+ end
135
+
136
+ Heading(level: 2) { "Anchoring turns" }
137
+
138
+ Text(class: "text-muted-foreground") { "Mark the row that starts a new turn with scroll_anchor. When it is appended, the viewport moves it near the top and keeps a peek of the previous item above it, so the new turn does not feel detached." }
139
+
140
+ render Docs::VisualCodeExample.new(title: "Anchored user turn", context: self) do
141
+ <<~RUBY
142
+ MessageScrollerProvider do
143
+ div(class: "h-80 w-full rounded-xl border bg-background") do
144
+ MessageScroller do
145
+ MessageScrollerViewport do
146
+ MessageScrollerContent(class: "p-4") do
147
+ MessageScrollerItem(scroll_anchor: true) do
148
+ Message(align: :end) do
149
+ MessageAvatar { Avatar(size: :sm) { AvatarFallback { "ME" } } }
150
+ MessageContent { Bubble { BubbleContent { "Can you summarize the deploy?" } } }
151
+ end
152
+ end
153
+ MessageScrollerItem do
154
+ Message do
155
+ MessageAvatar { Avatar(size: :sm) { AvatarFallback { "AI" } } }
156
+ MessageContent { Bubble(variant: :muted) { BubbleContent { "Shipped 3 PRs: the bubble surface, the message layout, and the scroller. All green." } } }
157
+ end
158
+ end
159
+ end
160
+ end
161
+ MessageScrollerButton()
162
+ end
163
+ end
164
+ end
165
+ RUBY
166
+ end
167
+
168
+ Heading(level: 2) { "Scroll commands" }
169
+
170
+ Text(class: "text-muted-foreground") { "The provider owns the scroll state, so controls placed anywhere inside it can drive the viewport. These buttons call the controller's scrollToStart and scrollToEnd actions directly." }
171
+
172
+ render Docs::VisualCodeExample.new(title: "Jump to start or end", context: self) do
173
+ <<~RUBY
174
+ MessageScrollerProvider(auto_scroll: false) do
175
+ div(class: "space-y-2") do
176
+ div(class: "flex gap-2") do
177
+ Button(variant: :outline, size: :sm, data: {action: "click->ruby-ui--message-scroller#scrollToStart"}) { "Jump to start" }
178
+ Button(variant: :outline, size: :sm, data: {action: "click->ruby-ui--message-scroller#scrollToEnd"}) { "Jump to latest" }
179
+ end
180
+ div(class: "h-72 w-full rounded-xl border bg-background") do
181
+ MessageScroller do
182
+ MessageScrollerViewport do
183
+ MessageScrollerContent(class: "p-4") do
184
+ 6.times do |i|
185
+ MessageScrollerItem do
186
+ Message(align: i.odd? ? :end : :start) do
187
+ MessageAvatar { Avatar(size: :sm) { AvatarFallback { i.odd? ? "ME" : "AI" } } }
188
+ MessageContent { Bubble(variant: i.odd? ? :default : :muted) { BubbleContent { "Message number \#{i + 1} in a longer thread." } } }
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ MessageScrollerButton()
195
+ end
196
+ end
197
+ end
198
+ end
199
+ RUBY
200
+ end
201
+
202
+ render Components::ComponentSetup::Tabs.new(component_name: component)
203
+
204
+ # components
205
+ render Docs::ComponentsTable.new(component_files(component))
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class MessageScrollerItem < Base
5
+ def initialize(scroll_anchor: false, message_id: nil, **attrs)
6
+ @scroll_anchor = scroll_anchor
7
+ @message_id = message_id
8
+ super(**attrs)
9
+ end
10
+
11
+ def view_template(&)
12
+ div(**attrs, &)
13
+ end
14
+
15
+ private
16
+
17
+ def default_attrs
18
+ data = {slot: "message-scroller-item"}
19
+ data[:scroll_anchor] = "" if @scroll_anchor
20
+ data[:message_id] = @message_id if @message_id
21
+
22
+ {
23
+ data: data,
24
+ class: "min-w-0 shrink-0"
25
+ }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class MessageScrollerProvider < Base
5
+ def initialize(auto_scroll: true, previous_item_peek: 64, default_position: :end, preserve_on_prepend: true, **attrs)
6
+ @auto_scroll = auto_scroll
7
+ @previous_item_peek = previous_item_peek
8
+ @default_position = default_position
9
+ @preserve_on_prepend = preserve_on_prepend
10
+ super(**attrs)
11
+ end
12
+
13
+ def view_template(&)
14
+ div(**attrs, &)
15
+ end
16
+
17
+ private
18
+
19
+ def default_attrs
20
+ {
21
+ data: {
22
+ slot: "message-scroller-provider",
23
+ controller: "ruby-ui--message-scroller",
24
+ ruby_ui__message_scroller_auto_scroll_value: @auto_scroll.to_s,
25
+ ruby_ui__message_scroller_previous_item_peek_value: @previous_item_peek,
26
+ ruby_ui__message_scroller_default_position_value: @default_position,
27
+ ruby_ui__message_scroller_preserve_on_prepend_value: @preserve_on_prepend.to_s
28
+ },
29
+ # display: contents — the provider owns scroll state without adding a box.
30
+ class: "contents"
31
+ }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class MessageScrollerViewport < Base
5
+ def view_template(&)
6
+ div(**attrs, &)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ tabindex: "0",
14
+ data: {
15
+ slot: "message-scroller-viewport",
16
+ ruby_ui__message_scroller_target: "viewport"
17
+ },
18
+ class: "size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain contain-content"
19
+ }
20
+ end
21
+ end
22
+ end
@@ -17,7 +17,7 @@ module RubyUI
17
17
  def icon
18
18
  svg(
19
19
  xmlns: "http://www.w3.org/2000/svg",
20
- viewbox: "0 0 24 24",
20
+ viewBox: "0 0 24 24",
21
21
  fill: "none",
22
22
  stroke: "currentColor",
23
23
  stroke_width: "2",
@@ -11,7 +11,8 @@ module RubyUI
11
11
  def default_attrs
12
12
  {
13
13
  data: {
14
- ruby_ui__popover_target: "content"
14
+ ruby_ui__popover_target: "content",
15
+ state: :closed
15
16
  },
16
17
  class: [
17
18
  "hidden z-50 rounded-md border bg-background p-1 text-foreground shadow-md outline-none",