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.
- checksums.yaml +4 -4
- data/README.md +6 -0
- data/lib/generators/ruby_ui/component/all_generator.rb +6 -4
- data/lib/generators/ruby_ui/component_generator.rb +51 -31
- data/lib/generators/ruby_ui/dependencies.yml +12 -2
- data/lib/generators/ruby_ui/javascript_utils.rb +0 -13
- data/lib/ruby_ui/accordion/accordion_content.rb +4 -2
- data/lib/ruby_ui/accordion/accordion_controller.js +28 -6
- data/lib/ruby_ui/avatar/avatar_image.rb +5 -1
- data/lib/ruby_ui/bubble/bubble.rb +37 -0
- data/lib/ruby_ui/bubble/bubble_content.rb +23 -0
- data/lib/ruby_ui/bubble/bubble_docs.rb +167 -0
- data/lib/ruby_ui/bubble/bubble_group.rb +18 -0
- data/lib/ruby_ui/bubble/bubble_reactions.rb +38 -0
- data/lib/ruby_ui/combobox/combobox.rb +3 -1
- data/lib/ruby_ui/combobox/combobox_checkbox.rb +8 -1
- data/lib/ruby_ui/combobox/combobox_controller.js +3 -2
- data/lib/ruby_ui/combobox/combobox_trigger.rb +1 -4
- data/lib/ruby_ui/context_menu/context_menu_content.rb +5 -6
- data/lib/ruby_ui/context_menu/context_menu_controller.js +87 -75
- data/lib/ruby_ui/data_table/data_table_column_toggle.rb +8 -4
- data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +12 -2
- data/lib/ruby_ui/data_table/data_table_docs.rb +1 -1
- data/lib/ruby_ui/dialog/dialog_content.rb +7 -19
- data/lib/ruby_ui/dialog/dialog_controller.js +22 -10
- data/lib/ruby_ui/dropdown_menu/dropdown_menu.rb +0 -1
- data/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js +5 -1
- data/lib/ruby_ui/dropdown_menu/dropdown_menu_docs.rb +1 -1
- data/lib/ruby_ui/dropdown_menu/dropdown_menu_item.rb +10 -4
- data/lib/ruby_ui/empty/empty.rb +18 -0
- data/lib/ruby_ui/empty/empty_content.rb +18 -0
- data/lib/ruby_ui/empty/empty_description.rb +18 -0
- data/lib/ruby_ui/empty/empty_docs.rb +69 -0
- data/lib/ruby_ui/empty/empty_header.rb +18 -0
- data/lib/ruby_ui/empty/empty_media.rb +31 -0
- data/lib/ruby_ui/empty/empty_title.rb +18 -0
- data/lib/ruby_ui/form/form_docs.rb +89 -0
- data/lib/ruby_ui/hover_card/hover_card_content.rb +5 -6
- data/lib/ruby_ui/hover_card/hover_card_controller.js +113 -76
- data/lib/ruby_ui/input_otp/input_otp.rb +42 -0
- data/lib/ruby_ui/input_otp/input_otp_controller.js +128 -0
- data/lib/ruby_ui/input_otp/input_otp_docs.rb +213 -0
- data/lib/ruby_ui/input_otp/input_otp_group.rb +15 -0
- data/lib/ruby_ui/input_otp/input_otp_separator.rb +39 -0
- data/lib/ruby_ui/input_otp/input_otp_slot.rb +33 -0
- data/lib/ruby_ui/message/message.rb +23 -0
- data/lib/ruby_ui/message/message_avatar.rb +18 -0
- data/lib/ruby_ui/message/message_content.rb +18 -0
- data/lib/ruby_ui/message/message_docs.rb +173 -0
- data/lib/ruby_ui/message/message_footer.rb +18 -0
- data/lib/ruby_ui/message/message_group.rb +18 -0
- data/lib/ruby_ui/message/message_header.rb +18 -0
- data/lib/ruby_ui/message_scroller/message_scroller.rb +18 -0
- data/lib/ruby_ui/message_scroller/message_scroller_button.rb +56 -0
- data/lib/ruby_ui/message_scroller/message_scroller_content.rb +23 -0
- data/lib/ruby_ui/message_scroller/message_scroller_controller.js +335 -0
- data/lib/ruby_ui/message_scroller/message_scroller_docs.rb +208 -0
- data/lib/ruby_ui/message_scroller/message_scroller_item.rb +28 -0
- data/lib/ruby_ui/message_scroller/message_scroller_provider.rb +34 -0
- data/lib/ruby_ui/message_scroller/message_scroller_viewport.rb +22 -0
- data/lib/ruby_ui/native_select/native_select_icon.rb +1 -1
- data/lib/ruby_ui/popover/popover_content.rb +2 -1
- data/lib/ruby_ui/popover/popover_controller.js +95 -32
- data/lib/ruby_ui/table/table_docs.rb +2 -2
- data/lib/ruby_ui/tabs/tabs_docs.rb +1 -1
- data/lib/ruby_ui/tabs/tabs_trigger.rb +10 -4
- data/lib/ruby_ui/toast/toaster_controller.js +24 -8
- data/lib/ruby_ui.rb +1 -1
- 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
|