Class: Views::Docs::MessageScroller

Inherits:
Base
  • Object
show all
Defined in:
lib/ruby_ui/message_scroller/message_scroller_docs.rb

Instance Method Summary collapse

Methods inherited from Base

#Alert, #AlertDescription, #AlertTitle, #Heading, #InlineCode, #InlineLink, #Text, #component_files, #docs_accordion_path, #docs_alert_dialog_path, #docs_alert_path, #docs_aspect_ratio_path, #docs_avatar_path, #docs_badge_path, #docs_installation_path, #docs_separator_path, #docs_sheet_path

Instance Method Details

#view_templateObject



4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/ruby_ui/message_scroller/message_scroller_docs.rb', line 4

def view_template
  component = "MessageScroller"

  div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
    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.")

    Heading(level: 2) { "Chat window" }

    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.)" }

    render Docs::VisualCodeExample.new(title: "Interactive chat", context: self) do
      <<~RUBY
        div(
          class: "mx-auto w-full max-w-sm",
          data: {
            controller: "message-scroller-chat",
            message_scroller_chat_replies_value: [
              "Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive.",
              "Set scroll_anchor on the turn that should settle near the top, and it keeps a peek of the previous exchange above it.",
              "Auto-scroll only runs when you are already at the bottom. Scroll up and your place stays put while new messages arrive below.",
              "When there is unseen content the jump-to-latest button appears — one tap returns you to the newest message."
            ].to_json
          }
        ) do
          Card(class: "flex flex-col h-[32rem] gap-0 overflow-hidden") do
            CardHeader(class: "gap-1 border-b pb-4") do
              CardTitle { "New Chat" }
              CardDescription { "How can I help you today?" }
            end
            CardContent(class: "flex-1 overflow-hidden p-0") do
              div(data: {message_scroller_chat_target: "empty"}, class: "h-full") do
                Empty(class: "h-full border-none") do
                  EmptyHeader do
                    EmptyMedia(variant: :icon) do
                      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|
                        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")
                      end
                    end
                    EmptyTitle { "Morning!" }
                    EmptyDescription { "What are we working on today? Press send to start a new conversation." }
                  end
                end
              end

              div(data: {message_scroller_chat_target: "scroller"}, class: "hidden h-full") do
                MessageScrollerProvider(auto_scroll: true) do
                  MessageScroller do
                    MessageScrollerViewport do
                      MessageScrollerContent(class: "p-4", data: {message_scroller_chat_target: "content"})
                    end
                    MessageScrollerButton()
                  end
                end
              end
            end
            CardFooter(class: "border-t pt-4") do
              form(data: {action: "submit->message-scroller-chat#send"}, class: "flex w-full items-center gap-2") do
                Input(type: "text", name: "message", placeholder: "Type a message…", autocomplete: "off", class: "flex-1", data: {message_scroller_chat_target: "input"})
                Button(type: "submit", class: "shrink-0") do
                  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|
                    s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18")
                  end
                  span(class: "sr-only") { "Send" }
                end
              end
            end
          end

          template(data: {message_scroller_chat_target: "userTemplate"}) do
            MessageScrollerItem(scroll_anchor: true) do
              Message(align: :end) do
                MessageAvatar { Avatar(size: :sm) { AvatarFallback { "ME" } } }
                MessageContent { Bubble { BubbleContent { "" } } }
              end
            end
          end

          template(data: {message_scroller_chat_target: "assistantTemplate"}) do
            MessageScrollerItem do
              Message do
                MessageAvatar { Avatar(size: :sm) { AvatarFallback { "AI" } } }
                MessageContent { Bubble(variant: :muted) { BubbleContent { "" } } }
              end
            end
          end
        end
      RUBY
    end

    Heading(level: 2) { "Usage" }

    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." }

    render Docs::VisualCodeExample.new(title: "Streaming chat", context: self) do
      <<~RUBY
        turns = [
          {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."},
          {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."},
          {role: :user, name: "ME", text: "But when someone sends a new message the view feels jarring, like the conversation reloads from the top."},
          {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."},
          {role: :user, name: "ME", text: "And if they scrolled up to re-read an older answer?"},
          {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."}
        ]

        MessageScrollerProvider(auto_scroll: true) do
          div(class: "h-96 w-full rounded-xl border bg-background") do
            MessageScroller do
              MessageScrollerViewport do
                MessageScrollerContent(class: "p-4") do
                  turns.each do |turn|
                    MessageScrollerItem(scroll_anchor: turn[:role] == :user) do
                      Message(align: turn[:role] == :user ? :end : :start) do
                        MessageAvatar do
                          Avatar(size: :sm) { AvatarFallback { turn[:name] } }
                        end
                        MessageContent do
                          Bubble(variant: turn[:role] == :user ? :default : :muted) do
                            BubbleContent { turn[:text] }
                          end
                        end
                      end
                    end
                  end
                end
              end
              MessageScrollerButton()
            end
          end
        end
      RUBY
    end

    Heading(level: 2) { "Anchoring turns" }

    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." }

    render Docs::VisualCodeExample.new(title: "Anchored user turn", context: self) do
      <<~RUBY
        MessageScrollerProvider do
          div(class: "h-80 w-full rounded-xl border bg-background") do
            MessageScroller do
              MessageScrollerViewport do
                MessageScrollerContent(class: "p-4") do
                  MessageScrollerItem(scroll_anchor: true) do
                    Message(align: :end) do
                      MessageAvatar { Avatar(size: :sm) { AvatarFallback { "ME" } } }
                      MessageContent { Bubble { BubbleContent { "Can you summarize the deploy?" } } }
                    end
                  end
                  MessageScrollerItem do
                    Message do
                      MessageAvatar { Avatar(size: :sm) { AvatarFallback { "AI" } } }
                      MessageContent { Bubble(variant: :muted) { BubbleContent { "Shipped 3 PRs: the bubble surface, the message layout, and the scroller. All green." } } }
                    end
                  end
                end
              end
              MessageScrollerButton()
            end
          end
        end
      RUBY
    end

    Heading(level: 2) { "Scroll commands" }

    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." }

    render Docs::VisualCodeExample.new(title: "Jump to start or end", context: self) do
      <<~RUBY
        MessageScrollerProvider(auto_scroll: false) do
          div(class: "space-y-2") do
            div(class: "flex gap-2") do
              Button(variant: :outline, size: :sm, data: {action: "click->ruby-ui--message-scroller#scrollToStart"}) { "Jump to start" }
              Button(variant: :outline, size: :sm, data: {action: "click->ruby-ui--message-scroller#scrollToEnd"}) { "Jump to latest" }
            end
            div(class: "h-72 w-full rounded-xl border bg-background") do
              MessageScroller do
                MessageScrollerViewport do
                  MessageScrollerContent(class: "p-4") do
                    6.times do |i|
                      MessageScrollerItem do
                        Message(align: i.odd? ? :end : :start) do
                          MessageAvatar { Avatar(size: :sm) { AvatarFallback { i.odd? ? "ME" : "AI" } } }
                          MessageContent { Bubble(variant: i.odd? ? :default : :muted) { BubbleContent { "Message number \#{i + 1} in a longer thread." } } }
                        end
                      end
                    end
                  end
                end
                MessageScrollerButton()
              end
            end
          end
        end
      RUBY
    end

    render Components::ComponentSetup::Tabs.new(component_name: component)

    # components
    render Docs::ComponentsTable.new(component_files(component))
  end
end