From 66b0f376850dd5c47e30b102e4db33cb12a961a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Tue, 30 Jun 2026 17:40:20 -0300 Subject: [PATCH 1/5] [Feature] Add Empty component Port the shadcn Empty component: a centered empty-state surface for when there is no data or content. Parts: Empty, EmptyHeader, EmptyMedia (default/icon variants), EmptyTitle, EmptyDescription, EmptyContent. Translates shadcn's cn-empty-* CSS layer to Tailwind v4 utilities. No JS. Docs page, route, controller, menu, site_files and MCP registry updated. --- docs/app/components/shared/components_list.rb | 1 + docs/app/controllers/docs_controller.rb | 4 ++ docs/app/lib/site_files.rb | 1 + docs/app/views/docs/empty.rb | 69 +++++++++++++++++++ docs/config/routes.rb | 1 + docs/public/llms-full.txt | 5 ++ docs/public/llms.txt | 1 + docs/public/sitemap.xml | 5 ++ gem/lib/ruby_ui/empty/empty.rb | 18 +++++ gem/lib/ruby_ui/empty/empty_content.rb | 18 +++++ gem/lib/ruby_ui/empty/empty_description.rb | 18 +++++ gem/lib/ruby_ui/empty/empty_docs.rb | 69 +++++++++++++++++++ gem/lib/ruby_ui/empty/empty_header.rb | 18 +++++ gem/lib/ruby_ui/empty/empty_media.rb | 31 +++++++++ gem/lib/ruby_ui/empty/empty_title.rb | 18 +++++ gem/test/ruby_ui/empty_test.rb | 39 +++++++++++ mcp/data/registry.json | 54 +++++++++++++++ 17 files changed, 370 insertions(+) create mode 100644 docs/app/views/docs/empty.rb create mode 100644 gem/lib/ruby_ui/empty/empty.rb create mode 100644 gem/lib/ruby_ui/empty/empty_content.rb create mode 100644 gem/lib/ruby_ui/empty/empty_description.rb create mode 100644 gem/lib/ruby_ui/empty/empty_docs.rb create mode 100644 gem/lib/ruby_ui/empty/empty_header.rb create mode 100644 gem/lib/ruby_ui/empty/empty_media.rb create mode 100644 gem/lib/ruby_ui/empty/empty_title.rb create mode 100644 gem/test/ruby_ui/empty_test.rb diff --git a/docs/app/components/shared/components_list.rb b/docs/app/components/shared/components_list.rb index eae5c84d..28112700 100644 --- a/docs/app/components/shared/components_list.rb +++ b/docs/app/components/shared/components_list.rb @@ -30,6 +30,7 @@ def components {name: "Date Picker", path: docs_date_picker_path}, {name: "Dialog / Modal", path: docs_dialog_path}, {name: "Dropdown Menu", path: docs_dropdown_menu_path}, + {name: "Empty", path: docs_empty_path}, {name: "Form", path: docs_form_path}, {name: "Hover Card", path: docs_hover_card_path}, {name: "Input", path: docs_input_path}, diff --git a/docs/app/controllers/docs_controller.rb b/docs/app/controllers/docs_controller.rb index d5e8473d..6b9d5a83 100644 --- a/docs/app/controllers/docs_controller.rb +++ b/docs/app/controllers/docs_controller.rb @@ -146,6 +146,10 @@ def dropdown_menu render Views::Docs::DropdownMenu.new end + def empty + render Views::Docs::Empty.new + end + def form render Views::Docs::Form.new end diff --git a/docs/app/lib/site_files.rb b/docs/app/lib/site_files.rb index b0984020..7eda2dfa 100644 --- a/docs/app/lib/site_files.rb +++ b/docs/app/lib/site_files.rb @@ -103,6 +103,7 @@ class SiteFiles {title: "Date Picker", path: "/docs/date_picker", description: "Date picker component with input."}, {title: "Dialog", path: "/docs/dialog", description: "Modal window that renders background content inert."}, {title: "Dropdown Menu", path: "/docs/dropdown_menu", description: "Button-triggered menu for actions or functions."}, + {title: "Empty", path: "/docs/empty", description: "Empty state for when there is no data or content."}, {title: "Form", path: "/docs/form", description: "Form fields with built-in client-side validations."}, {title: "Hover Card", path: "/docs/hover_card", description: "Preview content exposed behind a link or trigger."}, {title: "Input", path: "/docs/input", description: "Styled input field primitive."}, diff --git a/docs/app/views/docs/empty.rb b/docs/app/views/docs/empty.rb new file mode 100644 index 00000000..c69b3f43 --- /dev/null +++ b/docs/app/views/docs/empty.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class Views::Docs::Empty < Views::Base + def view_template + component = "Empty" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new(title: "Empty", description: "Use the empty component to display a state when there is no data or content.") + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Default", context: self) do + <<~RUBY + Empty 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: "M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155") + end + end + EmptyTitle { "No messages yet" } + EmptyDescription { "Start a conversation to see your messages here." } + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "With action", context: self) do + <<~RUBY + Empty 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: "M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z") + end + end + EmptyTitle { "No projects" } + EmptyDescription { "Get started by creating your first project." } + end + EmptyContent do + Button { "Create project" } + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Default media", context: self) do + <<~RUBY + Empty(class: "border-none") do + EmptyHeader do + EmptyMedia(variant: :default) do + Avatar(size: :lg) do + AvatarFallback { "RU" } + end + end + EmptyTitle { "No team members" } + EmptyDescription { "Invite your team to start collaborating." } + end + end + RUBY + end + + render Components::ComponentSetup::Tabs.new(component_name: component) + + # components + render Docs::ComponentsTable.new(component_files(component)) + end + end +end diff --git a/docs/config/routes.rb b/docs/config/routes.rb index 59702c7f..d739ebe2 100644 --- a/docs/config/routes.rb +++ b/docs/config/routes.rb @@ -47,6 +47,7 @@ get "date_picker", to: "docs#date_picker", as: :docs_date_picker get "dialog", to: "docs#dialog", as: :docs_dialog get "dropdown_menu", to: "docs#dropdown_menu", as: :docs_dropdown_menu + get "empty", to: "docs#empty", as: :docs_empty get "form", to: "docs#form", as: :docs_form get "hover_card", to: "docs#hover_card", as: :docs_hover_card get "input", to: "docs#input", as: :docs_input diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 3bd2e280..4ab58847 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -203,6 +203,11 @@ This file expands the curated /llms.txt map into a compact reference that can be - URL: https://rubyui.com/docs/dropdown_menu - Summary: Button-triggered menu for actions or functions. +### Empty + +- URL: https://rubyui.com/docs/empty +- Summary: Empty state for when there is no data or content. + ### Form - URL: https://rubyui.com/docs/form diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 722ad3ac..f2269efb 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -45,6 +45,7 @@ Use the core docs first for installation, theming, dark mode, and customization - [Date Picker](https://rubyui.com/docs/date_picker): Date picker component with input. - [Dialog](https://rubyui.com/docs/dialog): Modal window that renders background content inert. - [Dropdown Menu](https://rubyui.com/docs/dropdown_menu): Button-triggered menu for actions or functions. +- [Empty](https://rubyui.com/docs/empty): Empty state for when there is no data or content. - [Form](https://rubyui.com/docs/form): Form fields with built-in client-side validations. - [Hover Card](https://rubyui.com/docs/hover_card): Preview content exposed behind a link or trigger. - [Input](https://rubyui.com/docs/input): Styled input field primitive. diff --git a/docs/public/sitemap.xml b/docs/public/sitemap.xml index e3f5c942..518381a2 100644 --- a/docs/public/sitemap.xml +++ b/docs/public/sitemap.xml @@ -180,6 +180,11 @@ monthly 0.7 + + https://rubyui.com/docs/empty + monthly + 0.7 + https://rubyui.com/docs/form monthly diff --git a/gem/lib/ruby_ui/empty/empty.rb b/gem/lib/ruby_ui/empty/empty.rb new file mode 100644 index 00000000..1c315f8f --- /dev/null +++ b/gem/lib/ruby_ui/empty/empty.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class Empty < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "empty"}, + class: "flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-3xl border border-dashed p-12 text-center text-balance" + } + end + end +end diff --git a/gem/lib/ruby_ui/empty/empty_content.rb b/gem/lib/ruby_ui/empty/empty_content.rb new file mode 100644 index 00000000..eae0b46d --- /dev/null +++ b/gem/lib/ruby_ui/empty/empty_content.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class EmptyContent < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "empty-content"}, + class: "flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance" + } + end + end +end diff --git a/gem/lib/ruby_ui/empty/empty_description.rb b/gem/lib/ruby_ui/empty/empty_description.rb new file mode 100644 index 00000000..147bcafb --- /dev/null +++ b/gem/lib/ruby_ui/empty/empty_description.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class EmptyDescription < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "empty-description"}, + class: "text-sm leading-relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary" + } + end + end +end diff --git a/gem/lib/ruby_ui/empty/empty_docs.rb b/gem/lib/ruby_ui/empty/empty_docs.rb new file mode 100644 index 00000000..c69b3f43 --- /dev/null +++ b/gem/lib/ruby_ui/empty/empty_docs.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class Views::Docs::Empty < Views::Base + def view_template + component = "Empty" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new(title: "Empty", description: "Use the empty component to display a state when there is no data or content.") + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Default", context: self) do + <<~RUBY + Empty 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: "M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155") + end + end + EmptyTitle { "No messages yet" } + EmptyDescription { "Start a conversation to see your messages here." } + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "With action", context: self) do + <<~RUBY + Empty 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: "M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z") + end + end + EmptyTitle { "No projects" } + EmptyDescription { "Get started by creating your first project." } + end + EmptyContent do + Button { "Create project" } + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Default media", context: self) do + <<~RUBY + Empty(class: "border-none") do + EmptyHeader do + EmptyMedia(variant: :default) do + Avatar(size: :lg) do + AvatarFallback { "RU" } + end + end + EmptyTitle { "No team members" } + EmptyDescription { "Invite your team to start collaborating." } + end + end + RUBY + end + + render Components::ComponentSetup::Tabs.new(component_name: component) + + # components + render Docs::ComponentsTable.new(component_files(component)) + end + end +end diff --git a/gem/lib/ruby_ui/empty/empty_header.rb b/gem/lib/ruby_ui/empty/empty_header.rb new file mode 100644 index 00000000..8207b0cc --- /dev/null +++ b/gem/lib/ruby_ui/empty/empty_header.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class EmptyHeader < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "empty-header"}, + class: "flex max-w-sm flex-col items-center gap-2" + } + end + end +end diff --git a/gem/lib/ruby_ui/empty/empty_media.rb b/gem/lib/ruby_ui/empty/empty_media.rb new file mode 100644 index 00000000..231d110b --- /dev/null +++ b/gem/lib/ruby_ui/empty/empty_media.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module RubyUI + class EmptyMedia < Base + VARIANTS = { + default: "bg-transparent", + icon: "size-10 rounded-xl bg-muted text-foreground [&_svg:not([class*='size-'])]:size-5" + } + + def initialize(variant: :default, **attrs) + @variant = variant + super(**attrs) + end + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "empty-icon", variant: @variant}, + class: [ + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + VARIANTS[@variant] + ] + } + end + end +end diff --git a/gem/lib/ruby_ui/empty/empty_title.rb b/gem/lib/ruby_ui/empty/empty_title.rb new file mode 100644 index 00000000..26570c1b --- /dev/null +++ b/gem/lib/ruby_ui/empty/empty_title.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class EmptyTitle < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "empty-title"}, + class: "text-lg font-medium tracking-tight" + } + end + end +end diff --git a/gem/test/ruby_ui/empty_test.rb b/gem/test/ruby_ui/empty_test.rb new file mode 100644 index 00000000..75205f50 --- /dev/null +++ b/gem/test/ruby_ui/empty_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::EmptyTest < ComponentTest + def test_renders_full_structure + output = phlex do + RubyUI.Empty do + RubyUI.EmptyHeader do + RubyUI.EmptyMedia(variant: :icon) { "icon" } + RubyUI.EmptyTitle { "Nothing here" } + RubyUI.EmptyDescription { "No content yet." } + end + RubyUI.EmptyContent { "action" } + end + end + + assert_match(/data-slot="empty"/, output) + assert_match(/data-slot="empty-header"/, output) + assert_match(/data-slot="empty-icon"/, output) + assert_match(/data-slot="empty-title"/, output) + assert_match(/data-slot="empty-description"/, output) + assert_match(/data-slot="empty-content"/, output) + assert_match(/Nothing here/, output) + end + + def test_media_default_variant + output = phlex { RubyUI.EmptyMedia { "x" } } + + assert_match(/data-variant="default"/, output) + end + + def test_media_icon_variant + output = phlex { RubyUI.EmptyMedia(variant: :icon) { "x" } } + + assert_match(/data-variant="icon"/, output) + assert_match(/bg-muted/, output) + end +end diff --git a/mcp/data/registry.json b/mcp/data/registry.json index 4922db45..aff6f5a4 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -1435,6 +1435,60 @@ } ] }, + "empty": { + "name": "Empty", + "description": "Use the empty component to display a state when there is no data or content.", + "files": [ + { + "path": "empty.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Empty < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"empty\"},\n class: \"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-3xl border border-dashed p-12 text-center text-balance\"\n }\n end\n end\nend\n" + }, + { + "path": "empty_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class EmptyContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"empty-content\"},\n class: \"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance\"\n }\n end\n end\nend\n" + }, + { + "path": "empty_description.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class EmptyDescription < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"empty-description\"},\n class: \"text-sm leading-relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary\"\n }\n end\n end\nend\n" + }, + { + "path": "empty_header.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class EmptyHeader < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"empty-header\"},\n class: \"flex max-w-sm flex-col items-center gap-2\"\n }\n end\n end\nend\n" + }, + { + "path": "empty_media.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class EmptyMedia < Base\n VARIANTS = {\n default: \"bg-transparent\",\n icon: \"size-10 rounded-xl bg-muted text-foreground [&_svg:not([class*='size-'])]:size-5\"\n }\n\n def initialize(variant: :default, **attrs)\n @variant = variant\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"empty-icon\", variant: @variant},\n class: [\n \"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n VARIANTS[@variant]\n ]\n }\n end\n end\nend\n" + }, + { + "path": "empty_title.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class EmptyTitle < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"empty-title\"},\n class: \"text-lg font-medium tracking-tight\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Empty", + "docs_markdown": "# Empty\n\nUse the empty component to display a state when there is no data or content.\n\n## Usage\n\n### Default\n\n```ruby\nEmpty do\n EmptyHeader do\n EmptyMedia(variant: :icon) do\n 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|\n s.path(stroke_linecap: \"round\", stroke_linejoin: \"round\", d: \"M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155\")\n end\n end\n EmptyTitle { \"No messages yet\" }\n EmptyDescription { \"Start a conversation to see your messages here.\" }\n end\nend\n```\n\n### With action\n\n```ruby\nEmpty do\n EmptyHeader do\n EmptyMedia(variant: :icon) do\n 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|\n s.path(stroke_linecap: \"round\", stroke_linejoin: \"round\", d: \"M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z\")\n end\n end\n EmptyTitle { \"No projects\" }\n EmptyDescription { \"Get started by creating your first project.\" }\n end\n EmptyContent do\n Button { \"Create project\" }\n end\nend\n```\n\n### Default media\n\n```ruby\nEmpty(class: \"border-none\") do\n EmptyHeader do\n EmptyMedia(variant: :default) do\n Avatar(size: :lg) do\n AvatarFallback { \"RU\" }\n end\n end\n EmptyTitle { \"No team members\" }\n EmptyDescription { \"Invite your team to start collaborating.\" }\n end\nend\n```", + "examples": [ + { + "title": "Default", + "code": "Empty do\n EmptyHeader do\n EmptyMedia(variant: :icon) do\n 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|\n s.path(stroke_linecap: \"round\", stroke_linejoin: \"round\", d: \"M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155\")\n end\n end\n EmptyTitle { \"No messages yet\" }\n EmptyDescription { \"Start a conversation to see your messages here.\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "With action", + "code": "Empty do\n EmptyHeader do\n EmptyMedia(variant: :icon) do\n 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|\n s.path(stroke_linecap: \"round\", stroke_linejoin: \"round\", d: \"M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z\")\n end\n end\n EmptyTitle { \"No projects\" }\n EmptyDescription { \"Get started by creating your first project.\" }\n end\n EmptyContent do\n Button { \"Create project\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Default media", + "code": "Empty(class: \"border-none\") do\n EmptyHeader do\n EmptyMedia(variant: :default) do\n Avatar(size: :lg) do\n AvatarFallback { \"RU\" }\n end\n end\n EmptyTitle { \"No team members\" }\n EmptyDescription { \"Invite your team to start collaborating.\" }\n end\nend\n", + "language": "ruby" + } + ] + }, "form": { "name": "Form", "description": "Building forms with built-in client-side validations.", From 20a26149a4f45cfed8cd6cd672881dfca45221e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Tue, 30 Jun 2026 13:15:05 -0300 Subject: [PATCH 2/5] [Feature] Add Message Scroller component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/app/components/shared/components_list.rb | 1 + docs/app/controllers/docs_controller.rb | 4 + docs/app/javascript/controllers/index.js | 9 +- .../ruby_ui/message_scroller_controller.js | 292 ++++++++++++++++++ docs/app/lib/site_files.rb | 1 + docs/app/views/docs/message_scroller.rb | 125 ++++++++ docs/config/routes.rb | 1 + docs/public/llms-full.txt | 5 + docs/public/llms.txt | 1 + docs/public/sitemap.xml | 5 + gem/lib/generators/ruby_ui/dependencies.yml | 5 + .../message_scroller/message_scroller.rb | 18 ++ .../message_scroller_button.rb | 56 ++++ .../message_scroller_content.rb | 23 ++ .../message_scroller_controller.js | 292 ++++++++++++++++++ .../message_scroller/message_scroller_docs.rb | 125 ++++++++ .../message_scroller/message_scroller_item.rb | 28 ++ .../message_scroller_provider.rb | 34 ++ .../message_scroller_viewport.rb | 22 ++ gem/test/ruby_ui/message_scroller_test.rb | 84 +++++ mcp/data/registry.json | 61 ++++ 21 files changed, 1189 insertions(+), 3 deletions(-) create mode 100644 docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js create mode 100644 docs/app/views/docs/message_scroller.rb create mode 100644 gem/lib/ruby_ui/message_scroller/message_scroller.rb create mode 100644 gem/lib/ruby_ui/message_scroller/message_scroller_button.rb create mode 100644 gem/lib/ruby_ui/message_scroller/message_scroller_content.rb create mode 100644 gem/lib/ruby_ui/message_scroller/message_scroller_controller.js create mode 100644 gem/lib/ruby_ui/message_scroller/message_scroller_docs.rb create mode 100644 gem/lib/ruby_ui/message_scroller/message_scroller_item.rb create mode 100644 gem/lib/ruby_ui/message_scroller/message_scroller_provider.rb create mode 100644 gem/lib/ruby_ui/message_scroller/message_scroller_viewport.rb create mode 100644 gem/test/ruby_ui/message_scroller_test.rb diff --git a/docs/app/components/shared/components_list.rb b/docs/app/components/shared/components_list.rb index 28112700..13377824 100644 --- a/docs/app/components/shared/components_list.rb +++ b/docs/app/components/shared/components_list.rb @@ -37,6 +37,7 @@ def components {name: "Link", path: docs_link_path}, {name: "Masked Input", path: masked_input_path}, {name: "Message", path: docs_message_path}, + {name: "Message Scroller", path: docs_message_scroller_path}, {name: "Pagination", path: docs_pagination_path}, {name: "Popover", path: docs_popover_path}, {name: "Progress", path: docs_progress_path}, diff --git a/docs/app/controllers/docs_controller.rb b/docs/app/controllers/docs_controller.rb index 6b9d5a83..05fc2476 100644 --- a/docs/app/controllers/docs_controller.rb +++ b/docs/app/controllers/docs_controller.rb @@ -174,6 +174,10 @@ def message render Views::Docs::Message.new end + def message_scroller + render Views::Docs::MessageScroller.new + end + def pagination render Views::Docs::Pagination.new end diff --git a/docs/app/javascript/controllers/index.js b/docs/app/javascript/controllers/index.js index 0dc2eb27..9ee53579 100644 --- a/docs/app/javascript/controllers/index.js +++ b/docs/app/javascript/controllers/index.js @@ -7,9 +7,6 @@ import { application } from "./application" import IframeThemeController from "./iframe_theme_controller" application.register("iframe-theme", IframeThemeController) -import ToastDemoController from "./toast_demo_controller" -application.register("toast-demo", ToastDemoController) - import RubyUi__AccordionController from "./ruby_ui/accordion_controller" application.register("ruby-ui--accordion", RubyUi__AccordionController) @@ -76,6 +73,9 @@ application.register("ruby-ui--hover-card", RubyUi__HoverCardController) import RubyUi__MaskedInputController from "./ruby_ui/masked_input_controller" application.register("ruby-ui--masked-input", RubyUi__MaskedInputController) +import RubyUi__MessageScrollerController from "./ruby_ui/message_scroller_controller" +application.register("ruby-ui--message-scroller", RubyUi__MessageScrollerController) + import RubyUi__PopoverController from "./ruby_ui/popover_controller" application.register("ruby-ui--popover", RubyUi__PopoverController) @@ -117,3 +117,6 @@ application.register("ruby-ui--tooltip", RubyUi__TooltipController) import SidebarMenuController from "./sidebar_menu_controller" application.register("sidebar-menu", SidebarMenuController) + +import ToastDemoController from "./toast_demo_controller" +application.register("toast-demo", ToastDemoController) diff --git a/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js b/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js new file mode 100644 index 00000000..97f2052d --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js @@ -0,0 +1,292 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="ruby-ui--message-scroller" +// +// A chat transcript scroller. Owns scroll state and behavior for a +// height-constrained message list: +// +// - autoScroll: follows the live edge while the reader is pinned to the +// bottom, and releases the moment they scroll, wheel, drag, or key away. +// - scrollAnchor: when a new anchored turn is appended, settles it near the +// top of the viewport keeping a peek of the previous turn above it. +// - defaultScrollPosition: where a freshly mounted transcript opens +// ("end", "start" or "last-anchor"). +// - preserveScrollOnPrepend: keeps the visible row fixed when older messages +// are loaded in above the current view. +// +// Public API (callable from other controllers/outlets or future +// streaming/ActionCable code): scrollToEnd(), scrollToStart(), +// scrollToMessage(id). New rows appended to the content target are picked up +// automatically via MutationObserver — no manual call needed. +export default class extends Controller { + static targets = ["viewport", "content", "button"]; + + static values = { + autoScroll: { type: Boolean, default: true }, + previousItemPeek: { type: Number, default: 64 }, + defaultPosition: { type: String, default: "end" }, + preserveOnPrepend: { type: Boolean, default: true }, + endThreshold: { type: Number, default: 32 }, + }; + + connect() { + // Reader is considered "following" the live edge until they move away. + this.following = true; + // True only while a programmatic scroll is in flight, so reader-intent + // handlers don't mistake our own scrolling for the reader's. + this.programmatic = false; + + this.onScroll = this.onScroll.bind(this); + this.onWheel = this.onWheel.bind(this); + this.onTouchStart = this.onTouchStart.bind(this); + this.onKeydown = this.onKeydown.bind(this); + + if (this.hasViewportTarget) { + this.viewportTarget.addEventListener("scroll", this.onScroll, { passive: true }); + this.viewportTarget.addEventListener("wheel", this.onWheel, { passive: true }); + this.viewportTarget.addEventListener("touchstart", this.onTouchStart, { passive: true }); + this.viewportTarget.addEventListener("keydown", this.onKeydown); + } + + if (this.hasContentTarget) { + // Announce streamed/added messages to assistive tech at a calm pace. + if (!this.contentTarget.hasAttribute("role")) { + this.contentTarget.setAttribute("role", "log"); + } + if (!this.contentTarget.hasAttribute("aria-relevant")) { + this.contentTarget.setAttribute("aria-relevant", "additions text"); + } + + this.observer = new MutationObserver((records) => this.onMutations(records)); + this.observer.observe(this.contentTarget, { + childList: true, + subtree: true, + characterData: true, + }); + } + + // Apply the opening position after layout settles. + requestAnimationFrame(() => { + this.applyDefaultPosition(); + this.updateButton(); + }); + } + + disconnect() { + if (this.hasViewportTarget) { + this.viewportTarget.removeEventListener("scroll", this.onScroll); + this.viewportTarget.removeEventListener("wheel", this.onWheel); + this.viewportTarget.removeEventListener("touchstart", this.onTouchStart); + this.viewportTarget.removeEventListener("keydown", this.onKeydown); + } + this.observer?.disconnect(); + if (this.animationFrame) cancelAnimationFrame(this.animationFrame); + } + + // --- Reader intent ------------------------------------------------------- + + onScroll() { + if (this.programmatic) return; + this.following = this.isAtEnd(); + this.updateButton(); + } + + // Any upward wheel is a deliberate move away from the live edge. + onWheel(event) { + if (event.deltaY < 0) this.release(); + } + + onTouchStart() { + // A touch that turns into an upward drag surfaces through onScroll; this + // just makes the release feel immediate when the reader grabs the list. + if (!this.isAtEnd()) this.release(); + } + + onKeydown(event) { + const navKeys = ["ArrowUp", "PageUp", "Home", "ArrowDown", "PageDown", "End", " "]; + if (navKeys.includes(event.key)) this.release(); + } + + release() { + if (this.programmatic) return; + this.following = false; + } + + // --- Mutations (new / prepended / streamed rows) ------------------------- + + onMutations(records) { + let appended = null; + let prependedHeight = 0; + + for (const record of records) { + if (record.type !== "childList") continue; + for (const node of record.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + if (record.previousSibling === null && record.nextSibling !== null) { + // Inserted above existing rows → history prepend. + prependedHeight += node.offsetHeight || 0; + } else { + // Inserted at (or after) the end → new turn. + appended = node; + } + } + } + + if (prependedHeight > 0 && this.preserveOnPrependValue) { + // Keep the reader's current row fixed while history loads in above. + this.viewportTarget.scrollTop += prependedHeight; + } + + if (appended) { + const anchor = appended.matches?.("[data-scroll-anchor]") + ? appended + : appended.querySelector?.("[data-scroll-anchor]"); + if (anchor) { + this.scrollToAnchor(anchor); + } else if (this.autoScrollValue && this.following) { + this.scrollToEnd(); + } + } else if (this.autoScrollValue && this.following) { + // No new row — text streamed into the last row. Stay pinned. + this.scrollToEnd("auto"); + } + + this.updateButton(); + } + + // --- Public scroll commands --------------------------------------------- + + scrollToEnd(behavior = "smooth") { + if (!this.hasViewportTarget) return; + this.following = true; + this.scrollTo(this.viewportTarget.scrollHeight, behavior); + } + + scrollToStart(behavior = "smooth") { + if (!this.hasViewportTarget) return; + this.following = false; + this.scrollTo(0, behavior); + } + + // Scroll a row with a matching messageId into view. Returns false when the + // target is not mounted. + scrollToMessage(id, behavior = "smooth") { + if (!this.hasContentTarget) return false; + const item = this.contentTarget.querySelector(`[data-message-id="${CSS.escape(id)}"]`); + if (!item) return false; + this.following = false; + this.scrollToAnchor(item, behavior); + return true; + } + + scrollToAnchor(item, behavior = "smooth") { + const top = Math.max(0, item.offsetTop - this.previousItemPeekValue); + this.scrollTo(top, behavior); + } + + // Bound to the scroll button's click action. + jumpToEnd() { + this.scrollToEnd(); + } + + // --- Internals ----------------------------------------------------------- + + // Native scrollTo({ behavior: "smooth" }) is unreliable on a contained, + // virtualized viewport, so we animate scrollTop ourselves with rAF. This + // gives us full control over completion (no scrollend dependency) and lets + // us honor reduced-motion. + scrollTo(top, behavior = "smooth") { + if (!this.hasViewportTarget) return; + const max = this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight; + const target = Math.max(0, Math.min(top, max)); + + this.programmatic = true; + this.element.setAttribute("data-autoscrolling", ""); + this.viewportTarget.setAttribute("data-autoscrolling", ""); + if (this.animationFrame) cancelAnimationFrame(this.animationFrame); + + if (behavior === "auto" || this.prefersReducedMotion()) { + this.viewportTarget.scrollTop = target; + this.finishScroll(); + return; + } + + const start = this.viewportTarget.scrollTop; + const distance = target - start; + const duration = 300; + let startTime = null; + + const step = (now) => { + if (startTime === null) startTime = now; + const t = Math.min(1, (now - startTime) / duration); + // easeOutCubic + const eased = 1 - Math.pow(1 - t, 3); + this.viewportTarget.scrollTop = start + distance * eased; + if (t < 1) { + this.animationFrame = requestAnimationFrame(step); + } else { + this.finishScroll(); + } + }; + this.animationFrame = requestAnimationFrame(step); + } + + finishScroll() { + this.programmatic = false; + this.element.removeAttribute("data-autoscrolling"); + this.viewportTarget?.removeAttribute("data-autoscrolling"); + this.following = this.isAtEnd(); + this.updateButton(); + } + + prefersReducedMotion() { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } + + applyDefaultPosition() { + if (!this.hasViewportTarget) return; + const position = this.defaultPositionValue; + + if (position === "start") { + this.following = false; + this.viewportTarget.scrollTop = 0; + return; + } + + if (position === "last-anchor") { + const anchors = this.contentTarget?.querySelectorAll("[data-scroll-anchor]"); + const last = anchors && anchors[anchors.length - 1]; + // Fall back to the end when there's no anchor, or the last turn already + // fits in the viewport. + if (last && last.offsetTop - this.previousItemPeekValue > 0) { + this.following = false; + this.viewportTarget.scrollTop = Math.max(0, last.offsetTop - this.previousItemPeekValue); + this.updateButton(); + return; + } + } + + // Default: open at the live edge. + this.following = true; + this.viewportTarget.scrollTop = this.viewportTarget.scrollHeight; + } + + isAtEnd() { + if (!this.hasViewportTarget) return true; + const { scrollTop, clientHeight, scrollHeight } = this.viewportTarget; + return scrollHeight - (scrollTop + clientHeight) <= this.endThresholdValue; + } + + hasOverflow() { + if (!this.hasViewportTarget) return false; + return this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight > this.endThresholdValue; + } + + updateButton() { + if (!this.hasButtonTarget) return; + const active = this.hasOverflow() && !this.isAtEnd(); + this.buttonTarget.setAttribute("data-active", active ? "true" : "false"); + // Remove the inert button from the tab order so there are no ghost stops. + this.buttonTarget.setAttribute("tabindex", active ? "0" : "-1"); + } +} diff --git a/docs/app/lib/site_files.rb b/docs/app/lib/site_files.rb index 7eda2dfa..14c6e817 100644 --- a/docs/app/lib/site_files.rb +++ b/docs/app/lib/site_files.rb @@ -110,6 +110,7 @@ class SiteFiles {title: "Link", path: "/docs/link", description: "Link component with button-like and underline variants."}, {title: "Masked Input", path: "/docs/masked_input", description: "Form input with an applied mask."}, {title: "Message", path: "/docs/message", description: "Chat message layout pairing an avatar with bubbles, headers, and footers."}, + {title: "Message Scroller", path: "/docs/message_scroller", description: "Chat scroll container that anchors turns, follows streamed output, and jumps to the latest message."}, {title: "Pagination", path: "/docs/pagination", description: "Page navigation with next and previous links."}, {title: "Popover", path: "/docs/popover", description: "Triggered rich content panel."}, {title: "Progress", path: "/docs/progress", description: "Progress bar for task completion state."}, diff --git a/docs/app/views/docs/message_scroller.rb b/docs/app/views/docs/message_scroller.rb new file mode 100644 index 00000000..1eaec33f --- /dev/null +++ b/docs/app/views/docs/message_scroller.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +class Views::Docs::MessageScroller < Views::Base + 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) { "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 +end diff --git a/docs/config/routes.rb b/docs/config/routes.rb index d739ebe2..4ce5a25d 100644 --- a/docs/config/routes.rb +++ b/docs/config/routes.rb @@ -54,6 +54,7 @@ get "link", to: "docs#link", as: :docs_link get "masked_input", to: "docs#masked_input", as: :masked_input get "message", to: "docs#message", as: :docs_message + get "message_scroller", to: "docs#message_scroller", as: :docs_message_scroller get "pagination", to: "docs#pagination", as: :docs_pagination get "popover", to: "docs#popover", as: :docs_popover get "progress", to: "docs#progress", as: :docs_progress diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 4ab58847..4a4b6101 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -238,6 +238,11 @@ This file expands the curated /llms.txt map into a compact reference that can be - URL: https://rubyui.com/docs/message - Summary: Chat message layout pairing an avatar with bubbles, headers, and footers. +### Message Scroller + +- URL: https://rubyui.com/docs/message_scroller +- Summary: Chat scroll container that anchors turns, follows streamed output, and jumps to the latest message. + ### Pagination - URL: https://rubyui.com/docs/pagination diff --git a/docs/public/llms.txt b/docs/public/llms.txt index f2269efb..80475b67 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -52,6 +52,7 @@ Use the core docs first for installation, theming, dark mode, and customization - [Link](https://rubyui.com/docs/link): Link component with button-like and underline variants. - [Masked Input](https://rubyui.com/docs/masked_input): Form input with an applied mask. - [Message](https://rubyui.com/docs/message): Chat message layout pairing an avatar with bubbles, headers, and footers. +- [Message Scroller](https://rubyui.com/docs/message_scroller): Chat scroll container that anchors turns, follows streamed output, and jumps to the latest message. - [Pagination](https://rubyui.com/docs/pagination): Page navigation with next and previous links. - [Popover](https://rubyui.com/docs/popover): Triggered rich content panel. - [Progress](https://rubyui.com/docs/progress): Progress bar for task completion state. diff --git a/docs/public/sitemap.xml b/docs/public/sitemap.xml index 518381a2..c98ae8ab 100644 --- a/docs/public/sitemap.xml +++ b/docs/public/sitemap.xml @@ -215,6 +215,11 @@ monthly 0.7 + + https://rubyui.com/docs/message_scroller + monthly + 0.7 + https://rubyui.com/docs/pagination monthly diff --git a/gem/lib/generators/ruby_ui/dependencies.yml b/gem/lib/generators/ruby_ui/dependencies.yml index 64e71c5d..7c126416 100644 --- a/gem/lib/generators/ruby_ui/dependencies.yml +++ b/gem/lib/generators/ruby_ui/dependencies.yml @@ -78,6 +78,11 @@ message: - "Avatar" - "Bubble" +message_scroller: + components: + - "Message" + - "Bubble" + pagination: components: - "Button" diff --git a/gem/lib/ruby_ui/message_scroller/message_scroller.rb b/gem/lib/ruby_ui/message_scroller/message_scroller.rb new file mode 100644 index 00000000..34476a64 --- /dev/null +++ b/gem/lib/ruby_ui/message_scroller/message_scroller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class MessageScroller < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "message-scroller"}, + class: "group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden" + } + end + end +end diff --git a/gem/lib/ruby_ui/message_scroller/message_scroller_button.rb b/gem/lib/ruby_ui/message_scroller/message_scroller_button.rb new file mode 100644 index 00000000..6f26ba5e --- /dev/null +++ b/gem/lib/ruby_ui/message_scroller/message_scroller_button.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module RubyUI + class MessageScrollerButton < Base + def initialize(direction: :end, **attrs) + @direction = direction + super(**attrs) + end + + def view_template(&) + button(**attrs) do + if block_given? + yield + else + default_icon + span(class: "sr-only") { (@direction == :start) ? "Scroll to start" : "Scroll to end" } + end + end + end + + private + + def default_attrs + { + type: "button", + tabindex: "-1", + data: { + slot: "message-scroller-button", + direction: @direction, + active: "false", + ruby_ui__message_scroller_target: "button", + action: "click->ruby-ui--message-scroller#jumpToEnd" + }, + class: "absolute left-1/2 z-10 -translate-x-1/2 inline-flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full data-[direction=start]:[&_svg]:rotate-180" + } + end + + def default_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewbox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "size-4" + ) do |s| + s.path(d: "M12 5v14") + s.path(d: "m19 12-7 7-7-7") + end + end + end +end diff --git a/gem/lib/ruby_ui/message_scroller/message_scroller_content.rb b/gem/lib/ruby_ui/message_scroller/message_scroller_content.rb new file mode 100644 index 00000000..d02d9443 --- /dev/null +++ b/gem/lib/ruby_ui/message_scroller/message_scroller_content.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module RubyUI + class MessageScrollerContent < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + role: "log", + aria_relevant: "additions text", + data: { + slot: "message-scroller-content", + ruby_ui__message_scroller_target: "content" + }, + class: "flex h-max min-h-full flex-col gap-8" + } + end + end +end diff --git a/gem/lib/ruby_ui/message_scroller/message_scroller_controller.js b/gem/lib/ruby_ui/message_scroller/message_scroller_controller.js new file mode 100644 index 00000000..97f2052d --- /dev/null +++ b/gem/lib/ruby_ui/message_scroller/message_scroller_controller.js @@ -0,0 +1,292 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="ruby-ui--message-scroller" +// +// A chat transcript scroller. Owns scroll state and behavior for a +// height-constrained message list: +// +// - autoScroll: follows the live edge while the reader is pinned to the +// bottom, and releases the moment they scroll, wheel, drag, or key away. +// - scrollAnchor: when a new anchored turn is appended, settles it near the +// top of the viewport keeping a peek of the previous turn above it. +// - defaultScrollPosition: where a freshly mounted transcript opens +// ("end", "start" or "last-anchor"). +// - preserveScrollOnPrepend: keeps the visible row fixed when older messages +// are loaded in above the current view. +// +// Public API (callable from other controllers/outlets or future +// streaming/ActionCable code): scrollToEnd(), scrollToStart(), +// scrollToMessage(id). New rows appended to the content target are picked up +// automatically via MutationObserver — no manual call needed. +export default class extends Controller { + static targets = ["viewport", "content", "button"]; + + static values = { + autoScroll: { type: Boolean, default: true }, + previousItemPeek: { type: Number, default: 64 }, + defaultPosition: { type: String, default: "end" }, + preserveOnPrepend: { type: Boolean, default: true }, + endThreshold: { type: Number, default: 32 }, + }; + + connect() { + // Reader is considered "following" the live edge until they move away. + this.following = true; + // True only while a programmatic scroll is in flight, so reader-intent + // handlers don't mistake our own scrolling for the reader's. + this.programmatic = false; + + this.onScroll = this.onScroll.bind(this); + this.onWheel = this.onWheel.bind(this); + this.onTouchStart = this.onTouchStart.bind(this); + this.onKeydown = this.onKeydown.bind(this); + + if (this.hasViewportTarget) { + this.viewportTarget.addEventListener("scroll", this.onScroll, { passive: true }); + this.viewportTarget.addEventListener("wheel", this.onWheel, { passive: true }); + this.viewportTarget.addEventListener("touchstart", this.onTouchStart, { passive: true }); + this.viewportTarget.addEventListener("keydown", this.onKeydown); + } + + if (this.hasContentTarget) { + // Announce streamed/added messages to assistive tech at a calm pace. + if (!this.contentTarget.hasAttribute("role")) { + this.contentTarget.setAttribute("role", "log"); + } + if (!this.contentTarget.hasAttribute("aria-relevant")) { + this.contentTarget.setAttribute("aria-relevant", "additions text"); + } + + this.observer = new MutationObserver((records) => this.onMutations(records)); + this.observer.observe(this.contentTarget, { + childList: true, + subtree: true, + characterData: true, + }); + } + + // Apply the opening position after layout settles. + requestAnimationFrame(() => { + this.applyDefaultPosition(); + this.updateButton(); + }); + } + + disconnect() { + if (this.hasViewportTarget) { + this.viewportTarget.removeEventListener("scroll", this.onScroll); + this.viewportTarget.removeEventListener("wheel", this.onWheel); + this.viewportTarget.removeEventListener("touchstart", this.onTouchStart); + this.viewportTarget.removeEventListener("keydown", this.onKeydown); + } + this.observer?.disconnect(); + if (this.animationFrame) cancelAnimationFrame(this.animationFrame); + } + + // --- Reader intent ------------------------------------------------------- + + onScroll() { + if (this.programmatic) return; + this.following = this.isAtEnd(); + this.updateButton(); + } + + // Any upward wheel is a deliberate move away from the live edge. + onWheel(event) { + if (event.deltaY < 0) this.release(); + } + + onTouchStart() { + // A touch that turns into an upward drag surfaces through onScroll; this + // just makes the release feel immediate when the reader grabs the list. + if (!this.isAtEnd()) this.release(); + } + + onKeydown(event) { + const navKeys = ["ArrowUp", "PageUp", "Home", "ArrowDown", "PageDown", "End", " "]; + if (navKeys.includes(event.key)) this.release(); + } + + release() { + if (this.programmatic) return; + this.following = false; + } + + // --- Mutations (new / prepended / streamed rows) ------------------------- + + onMutations(records) { + let appended = null; + let prependedHeight = 0; + + for (const record of records) { + if (record.type !== "childList") continue; + for (const node of record.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + if (record.previousSibling === null && record.nextSibling !== null) { + // Inserted above existing rows → history prepend. + prependedHeight += node.offsetHeight || 0; + } else { + // Inserted at (or after) the end → new turn. + appended = node; + } + } + } + + if (prependedHeight > 0 && this.preserveOnPrependValue) { + // Keep the reader's current row fixed while history loads in above. + this.viewportTarget.scrollTop += prependedHeight; + } + + if (appended) { + const anchor = appended.matches?.("[data-scroll-anchor]") + ? appended + : appended.querySelector?.("[data-scroll-anchor]"); + if (anchor) { + this.scrollToAnchor(anchor); + } else if (this.autoScrollValue && this.following) { + this.scrollToEnd(); + } + } else if (this.autoScrollValue && this.following) { + // No new row — text streamed into the last row. Stay pinned. + this.scrollToEnd("auto"); + } + + this.updateButton(); + } + + // --- Public scroll commands --------------------------------------------- + + scrollToEnd(behavior = "smooth") { + if (!this.hasViewportTarget) return; + this.following = true; + this.scrollTo(this.viewportTarget.scrollHeight, behavior); + } + + scrollToStart(behavior = "smooth") { + if (!this.hasViewportTarget) return; + this.following = false; + this.scrollTo(0, behavior); + } + + // Scroll a row with a matching messageId into view. Returns false when the + // target is not mounted. + scrollToMessage(id, behavior = "smooth") { + if (!this.hasContentTarget) return false; + const item = this.contentTarget.querySelector(`[data-message-id="${CSS.escape(id)}"]`); + if (!item) return false; + this.following = false; + this.scrollToAnchor(item, behavior); + return true; + } + + scrollToAnchor(item, behavior = "smooth") { + const top = Math.max(0, item.offsetTop - this.previousItemPeekValue); + this.scrollTo(top, behavior); + } + + // Bound to the scroll button's click action. + jumpToEnd() { + this.scrollToEnd(); + } + + // --- Internals ----------------------------------------------------------- + + // Native scrollTo({ behavior: "smooth" }) is unreliable on a contained, + // virtualized viewport, so we animate scrollTop ourselves with rAF. This + // gives us full control over completion (no scrollend dependency) and lets + // us honor reduced-motion. + scrollTo(top, behavior = "smooth") { + if (!this.hasViewportTarget) return; + const max = this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight; + const target = Math.max(0, Math.min(top, max)); + + this.programmatic = true; + this.element.setAttribute("data-autoscrolling", ""); + this.viewportTarget.setAttribute("data-autoscrolling", ""); + if (this.animationFrame) cancelAnimationFrame(this.animationFrame); + + if (behavior === "auto" || this.prefersReducedMotion()) { + this.viewportTarget.scrollTop = target; + this.finishScroll(); + return; + } + + const start = this.viewportTarget.scrollTop; + const distance = target - start; + const duration = 300; + let startTime = null; + + const step = (now) => { + if (startTime === null) startTime = now; + const t = Math.min(1, (now - startTime) / duration); + // easeOutCubic + const eased = 1 - Math.pow(1 - t, 3); + this.viewportTarget.scrollTop = start + distance * eased; + if (t < 1) { + this.animationFrame = requestAnimationFrame(step); + } else { + this.finishScroll(); + } + }; + this.animationFrame = requestAnimationFrame(step); + } + + finishScroll() { + this.programmatic = false; + this.element.removeAttribute("data-autoscrolling"); + this.viewportTarget?.removeAttribute("data-autoscrolling"); + this.following = this.isAtEnd(); + this.updateButton(); + } + + prefersReducedMotion() { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } + + applyDefaultPosition() { + if (!this.hasViewportTarget) return; + const position = this.defaultPositionValue; + + if (position === "start") { + this.following = false; + this.viewportTarget.scrollTop = 0; + return; + } + + if (position === "last-anchor") { + const anchors = this.contentTarget?.querySelectorAll("[data-scroll-anchor]"); + const last = anchors && anchors[anchors.length - 1]; + // Fall back to the end when there's no anchor, or the last turn already + // fits in the viewport. + if (last && last.offsetTop - this.previousItemPeekValue > 0) { + this.following = false; + this.viewportTarget.scrollTop = Math.max(0, last.offsetTop - this.previousItemPeekValue); + this.updateButton(); + return; + } + } + + // Default: open at the live edge. + this.following = true; + this.viewportTarget.scrollTop = this.viewportTarget.scrollHeight; + } + + isAtEnd() { + if (!this.hasViewportTarget) return true; + const { scrollTop, clientHeight, scrollHeight } = this.viewportTarget; + return scrollHeight - (scrollTop + clientHeight) <= this.endThresholdValue; + } + + hasOverflow() { + if (!this.hasViewportTarget) return false; + return this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight > this.endThresholdValue; + } + + updateButton() { + if (!this.hasButtonTarget) return; + const active = this.hasOverflow() && !this.isAtEnd(); + this.buttonTarget.setAttribute("data-active", active ? "true" : "false"); + // Remove the inert button from the tab order so there are no ghost stops. + this.buttonTarget.setAttribute("tabindex", active ? "0" : "-1"); + } +} diff --git a/gem/lib/ruby_ui/message_scroller/message_scroller_docs.rb b/gem/lib/ruby_ui/message_scroller/message_scroller_docs.rb new file mode 100644 index 00000000..1eaec33f --- /dev/null +++ b/gem/lib/ruby_ui/message_scroller/message_scroller_docs.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +class Views::Docs::MessageScroller < Views::Base + 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) { "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 +end diff --git a/gem/lib/ruby_ui/message_scroller/message_scroller_item.rb b/gem/lib/ruby_ui/message_scroller/message_scroller_item.rb new file mode 100644 index 00000000..9cab0994 --- /dev/null +++ b/gem/lib/ruby_ui/message_scroller/message_scroller_item.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module RubyUI + class MessageScrollerItem < Base + def initialize(scroll_anchor: false, message_id: nil, **attrs) + @scroll_anchor = scroll_anchor + @message_id = message_id + super(**attrs) + end + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + data = {slot: "message-scroller-item"} + data[:scroll_anchor] = "" if @scroll_anchor + data[:message_id] = @message_id if @message_id + + { + data: data, + class: "min-w-0 shrink-0" + } + end + end +end diff --git a/gem/lib/ruby_ui/message_scroller/message_scroller_provider.rb b/gem/lib/ruby_ui/message_scroller/message_scroller_provider.rb new file mode 100644 index 00000000..751f25c3 --- /dev/null +++ b/gem/lib/ruby_ui/message_scroller/message_scroller_provider.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module RubyUI + class MessageScrollerProvider < Base + def initialize(auto_scroll: true, previous_item_peek: 64, default_position: :end, preserve_on_prepend: true, **attrs) + @auto_scroll = auto_scroll + @previous_item_peek = previous_item_peek + @default_position = default_position + @preserve_on_prepend = preserve_on_prepend + super(**attrs) + end + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: { + slot: "message-scroller-provider", + controller: "ruby-ui--message-scroller", + ruby_ui__message_scroller_auto_scroll_value: @auto_scroll.to_s, + ruby_ui__message_scroller_previous_item_peek_value: @previous_item_peek, + ruby_ui__message_scroller_default_position_value: @default_position, + ruby_ui__message_scroller_preserve_on_prepend_value: @preserve_on_prepend.to_s + }, + # display: contents — the provider owns scroll state without adding a box. + class: "contents" + } + end + end +end diff --git a/gem/lib/ruby_ui/message_scroller/message_scroller_viewport.rb b/gem/lib/ruby_ui/message_scroller/message_scroller_viewport.rb new file mode 100644 index 00000000..632273e1 --- /dev/null +++ b/gem/lib/ruby_ui/message_scroller/message_scroller_viewport.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module RubyUI + class MessageScrollerViewport < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + tabindex: "0", + data: { + slot: "message-scroller-viewport", + ruby_ui__message_scroller_target: "viewport" + }, + class: "size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain contain-content" + } + end + end +end diff --git a/gem/test/ruby_ui/message_scroller_test.rb b/gem/test/ruby_ui/message_scroller_test.rb new file mode 100644 index 00000000..dbfce3a3 --- /dev/null +++ b/gem/test/ruby_ui/message_scroller_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::MessageScrollerTest < ComponentTest + def test_renders_full_structure + output = phlex do + RubyUI.MessageScrollerProvider do + RubyUI.MessageScroller do + RubyUI.MessageScrollerViewport do + RubyUI.MessageScrollerContent do + RubyUI.MessageScrollerItem { "row" } + end + end + RubyUI.MessageScrollerButton + end + end + end + + assert_match(/data-controller="ruby-ui--message-scroller"/, output) + assert_match(/data-slot="message-scroller"/, output) + assert_match(/data-slot="message-scroller-viewport"/, output) + assert_match(/data-slot="message-scroller-content"/, output) + assert_match(/data-slot="message-scroller-item"/, output) + assert_match(/data-slot="message-scroller-button"/, output) + end + + def test_provider_values_default + output = phlex { RubyUI.MessageScrollerProvider { "x" } } + + assert_match(/message-scroller-auto-scroll-value="true"/, output) + assert_match(/message-scroller-previous-item-peek-value="64"/, output) + assert_match(/message-scroller-default-position-value="end"/, output) + assert_match(/message-scroller-preserve-on-prepend-value="true"/, output) + end + + def test_provider_values_custom + output = phlex do + RubyUI.MessageScrollerProvider(auto_scroll: false, previous_item_peek: 32, default_position: :last_anchor) { "x" } + end + + assert_match(/message-scroller-auto-scroll-value="false"/, output) + assert_match(/message-scroller-previous-item-peek-value="32"/, output) + assert_match(/message-scroller-default-position-value="last-anchor"/, output) + end + + def test_content_live_region + output = phlex { RubyUI.MessageScrollerContent { "x" } } + + assert_match(/role="log"/, output) + assert_match(/aria-relevant="additions text"/, output) + end + + def test_item_scroll_anchor_and_message_id + output = phlex do + RubyUI.MessageScrollerItem(scroll_anchor: true, message_id: "m1") { "row" } + end + + assert_match(/data-scroll-anchor/, output) + assert_match(/data-message-id="m1"/, output) + end + + def test_item_without_anchor + output = phlex { RubyUI.MessageScrollerItem { "row" } } + + refute_match(/data-scroll-anchor/, output) + end + + def test_button_targets_and_action + output = phlex { RubyUI.MessageScrollerButton } + + assert_match(/message-scroller-target="button"/, output) + assert_match(/click->ruby-ui--message-scroller#jumpToEnd/, output) + assert_match(/data-direction="end"/, output) + assert_match(/Scroll to end/, output) + end + + def test_button_start_direction + output = phlex { RubyUI.MessageScrollerButton(direction: :start) } + + assert_match(/data-direction="start"/, output) + assert_match(/Scroll to start/, output) + end +end diff --git a/mcp/data/registry.json b/mcp/data/registry.json index aff6f5a4..54732158 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -1818,6 +1818,67 @@ } ] }, + "message_scroller": { + "name": "MessageScroller", + "description": "A chat scroll container that anchors turns, follows streamed responses, preserves position when older messages load, and jumps to the latest message.", + "files": [ + { + "path": "message_scroller.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageScroller < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"message-scroller\"},\n class: \"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden\"\n }\n end\n end\nend\n" + }, + { + "path": "message_scroller_button.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageScrollerButton < Base\n def initialize(direction: :end, **attrs)\n @direction = direction\n super(**attrs)\n end\n\n def view_template(&)\n button(**attrs) do\n if block_given?\n yield\n else\n default_icon\n span(class: \"sr-only\") { (@direction == :start) ? \"Scroll to start\" : \"Scroll to end\" }\n end\n end\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n tabindex: \"-1\",\n data: {\n slot: \"message-scroller-button\",\n direction: @direction,\n active: \"false\",\n ruby_ui__message_scroller_target: \"button\",\n action: \"click->ruby-ui--message-scroller#jumpToEnd\"\n },\n class: \"absolute left-1/2 z-10 -translate-x-1/2 inline-flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full data-[direction=start]:[&_svg]:rotate-180\"\n }\n end\n\n def default_icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"size-4\"\n ) do |s|\n s.path(d: \"M12 5v14\")\n s.path(d: \"m19 12-7 7-7-7\")\n end\n end\n end\nend\n" + }, + { + "path": "message_scroller_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageScrollerContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"log\",\n aria_relevant: \"additions text\",\n data: {\n slot: \"message-scroller-content\",\n ruby_ui__message_scroller_target: \"content\"\n },\n class: \"flex h-max min-h-full flex-col gap-8\"\n }\n end\n end\nend\n" + }, + { + "path": "message_scroller_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--message-scroller\"\n//\n// A chat transcript scroller. Owns scroll state and behavior for a\n// height-constrained message list:\n//\n// - autoScroll: follows the live edge while the reader is pinned to the\n// bottom, and releases the moment they scroll, wheel, drag, or key away.\n// - scrollAnchor: when a new anchored turn is appended, settles it near the\n// top of the viewport keeping a peek of the previous turn above it.\n// - defaultScrollPosition: where a freshly mounted transcript opens\n// (\"end\", \"start\" or \"last-anchor\").\n// - preserveScrollOnPrepend: keeps the visible row fixed when older messages\n// are loaded in above the current view.\n//\n// Public API (callable from other controllers/outlets or future\n// streaming/ActionCable code): scrollToEnd(), scrollToStart(),\n// scrollToMessage(id). New rows appended to the content target are picked up\n// automatically via MutationObserver — no manual call needed.\nexport default class extends Controller {\n static targets = [\"viewport\", \"content\", \"button\"];\n\n static values = {\n autoScroll: { type: Boolean, default: true },\n previousItemPeek: { type: Number, default: 64 },\n defaultPosition: { type: String, default: \"end\" },\n preserveOnPrepend: { type: Boolean, default: true },\n endThreshold: { type: Number, default: 32 },\n };\n\n connect() {\n // Reader is considered \"following\" the live edge until they move away.\n this.following = true;\n // True only while a programmatic scroll is in flight, so reader-intent\n // handlers don't mistake our own scrolling for the reader's.\n this.programmatic = false;\n\n this.onScroll = this.onScroll.bind(this);\n this.onWheel = this.onWheel.bind(this);\n this.onTouchStart = this.onTouchStart.bind(this);\n this.onKeydown = this.onKeydown.bind(this);\n\n if (this.hasViewportTarget) {\n this.viewportTarget.addEventListener(\"scroll\", this.onScroll, { passive: true });\n this.viewportTarget.addEventListener(\"wheel\", this.onWheel, { passive: true });\n this.viewportTarget.addEventListener(\"touchstart\", this.onTouchStart, { passive: true });\n this.viewportTarget.addEventListener(\"keydown\", this.onKeydown);\n }\n\n if (this.hasContentTarget) {\n // Announce streamed/added messages to assistive tech at a calm pace.\n if (!this.contentTarget.hasAttribute(\"role\")) {\n this.contentTarget.setAttribute(\"role\", \"log\");\n }\n if (!this.contentTarget.hasAttribute(\"aria-relevant\")) {\n this.contentTarget.setAttribute(\"aria-relevant\", \"additions text\");\n }\n\n this.observer = new MutationObserver((records) => this.onMutations(records));\n this.observer.observe(this.contentTarget, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n\n // Apply the opening position after layout settles.\n requestAnimationFrame(() => {\n this.applyDefaultPosition();\n this.updateButton();\n });\n }\n\n disconnect() {\n if (this.hasViewportTarget) {\n this.viewportTarget.removeEventListener(\"scroll\", this.onScroll);\n this.viewportTarget.removeEventListener(\"wheel\", this.onWheel);\n this.viewportTarget.removeEventListener(\"touchstart\", this.onTouchStart);\n this.viewportTarget.removeEventListener(\"keydown\", this.onKeydown);\n }\n this.observer?.disconnect();\n if (this.animationFrame) cancelAnimationFrame(this.animationFrame);\n }\n\n // --- Reader intent -------------------------------------------------------\n\n onScroll() {\n if (this.programmatic) return;\n this.following = this.isAtEnd();\n this.updateButton();\n }\n\n // Any upward wheel is a deliberate move away from the live edge.\n onWheel(event) {\n if (event.deltaY < 0) this.release();\n }\n\n onTouchStart() {\n // A touch that turns into an upward drag surfaces through onScroll; this\n // just makes the release feel immediate when the reader grabs the list.\n if (!this.isAtEnd()) this.release();\n }\n\n onKeydown(event) {\n const navKeys = [\"ArrowUp\", \"PageUp\", \"Home\", \"ArrowDown\", \"PageDown\", \"End\", \" \"];\n if (navKeys.includes(event.key)) this.release();\n }\n\n release() {\n if (this.programmatic) return;\n this.following = false;\n }\n\n // --- Mutations (new / prepended / streamed rows) -------------------------\n\n onMutations(records) {\n let appended = null;\n let prependedHeight = 0;\n\n for (const record of records) {\n if (record.type !== \"childList\") continue;\n for (const node of record.addedNodes) {\n if (node.nodeType !== Node.ELEMENT_NODE) continue;\n if (record.previousSibling === null && record.nextSibling !== null) {\n // Inserted above existing rows → history prepend.\n prependedHeight += node.offsetHeight || 0;\n } else {\n // Inserted at (or after) the end → new turn.\n appended = node;\n }\n }\n }\n\n if (prependedHeight > 0 && this.preserveOnPrependValue) {\n // Keep the reader's current row fixed while history loads in above.\n this.viewportTarget.scrollTop += prependedHeight;\n }\n\n if (appended) {\n const anchor = appended.matches?.(\"[data-scroll-anchor]\")\n ? appended\n : appended.querySelector?.(\"[data-scroll-anchor]\");\n if (anchor) {\n this.scrollToAnchor(anchor);\n } else if (this.autoScrollValue && this.following) {\n this.scrollToEnd();\n }\n } else if (this.autoScrollValue && this.following) {\n // No new row — text streamed into the last row. Stay pinned.\n this.scrollToEnd(\"auto\");\n }\n\n this.updateButton();\n }\n\n // --- Public scroll commands ---------------------------------------------\n\n scrollToEnd(behavior = \"smooth\") {\n if (!this.hasViewportTarget) return;\n this.following = true;\n this.scrollTo(this.viewportTarget.scrollHeight, behavior);\n }\n\n scrollToStart(behavior = \"smooth\") {\n if (!this.hasViewportTarget) return;\n this.following = false;\n this.scrollTo(0, behavior);\n }\n\n // Scroll a row with a matching messageId into view. Returns false when the\n // target is not mounted.\n scrollToMessage(id, behavior = \"smooth\") {\n if (!this.hasContentTarget) return false;\n const item = this.contentTarget.querySelector(`[data-message-id=\"${CSS.escape(id)}\"]`);\n if (!item) return false;\n this.following = false;\n this.scrollToAnchor(item, behavior);\n return true;\n }\n\n scrollToAnchor(item, behavior = \"smooth\") {\n const top = Math.max(0, item.offsetTop - this.previousItemPeekValue);\n this.scrollTo(top, behavior);\n }\n\n // Bound to the scroll button's click action.\n jumpToEnd() {\n this.scrollToEnd();\n }\n\n // --- Internals -----------------------------------------------------------\n\n // Native scrollTo({ behavior: \"smooth\" }) is unreliable on a contained,\n // virtualized viewport, so we animate scrollTop ourselves with rAF. This\n // gives us full control over completion (no scrollend dependency) and lets\n // us honor reduced-motion.\n scrollTo(top, behavior = \"smooth\") {\n if (!this.hasViewportTarget) return;\n const max = this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight;\n const target = Math.max(0, Math.min(top, max));\n\n this.programmatic = true;\n this.element.setAttribute(\"data-autoscrolling\", \"\");\n this.viewportTarget.setAttribute(\"data-autoscrolling\", \"\");\n if (this.animationFrame) cancelAnimationFrame(this.animationFrame);\n\n if (behavior === \"auto\" || this.prefersReducedMotion()) {\n this.viewportTarget.scrollTop = target;\n this.finishScroll();\n return;\n }\n\n const start = this.viewportTarget.scrollTop;\n const distance = target - start;\n const duration = 300;\n let startTime = null;\n\n const step = (now) => {\n if (startTime === null) startTime = now;\n const t = Math.min(1, (now - startTime) / duration);\n // easeOutCubic\n const eased = 1 - Math.pow(1 - t, 3);\n this.viewportTarget.scrollTop = start + distance * eased;\n if (t < 1) {\n this.animationFrame = requestAnimationFrame(step);\n } else {\n this.finishScroll();\n }\n };\n this.animationFrame = requestAnimationFrame(step);\n }\n\n finishScroll() {\n this.programmatic = false;\n this.element.removeAttribute(\"data-autoscrolling\");\n this.viewportTarget?.removeAttribute(\"data-autoscrolling\");\n this.following = this.isAtEnd();\n this.updateButton();\n }\n\n prefersReducedMotion() {\n return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n }\n\n applyDefaultPosition() {\n if (!this.hasViewportTarget) return;\n const position = this.defaultPositionValue;\n\n if (position === \"start\") {\n this.following = false;\n this.viewportTarget.scrollTop = 0;\n return;\n }\n\n if (position === \"last-anchor\") {\n const anchors = this.contentTarget?.querySelectorAll(\"[data-scroll-anchor]\");\n const last = anchors && anchors[anchors.length - 1];\n // Fall back to the end when there's no anchor, or the last turn already\n // fits in the viewport.\n if (last && last.offsetTop - this.previousItemPeekValue > 0) {\n this.following = false;\n this.viewportTarget.scrollTop = Math.max(0, last.offsetTop - this.previousItemPeekValue);\n this.updateButton();\n return;\n }\n }\n\n // Default: open at the live edge.\n this.following = true;\n this.viewportTarget.scrollTop = this.viewportTarget.scrollHeight;\n }\n\n isAtEnd() {\n if (!this.hasViewportTarget) return true;\n const { scrollTop, clientHeight, scrollHeight } = this.viewportTarget;\n return scrollHeight - (scrollTop + clientHeight) <= this.endThresholdValue;\n }\n\n hasOverflow() {\n if (!this.hasViewportTarget) return false;\n return this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight > this.endThresholdValue;\n }\n\n updateButton() {\n if (!this.hasButtonTarget) return;\n const active = this.hasOverflow() && !this.isAtEnd();\n this.buttonTarget.setAttribute(\"data-active\", active ? \"true\" : \"false\");\n // Remove the inert button from the tab order so there are no ghost stops.\n this.buttonTarget.setAttribute(\"tabindex\", active ? \"0\" : \"-1\");\n }\n}\n" + }, + { + "path": "message_scroller_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageScrollerItem < Base\n def initialize(scroll_anchor: false, message_id: nil, **attrs)\n @scroll_anchor = scroll_anchor\n @message_id = message_id\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n data = {slot: \"message-scroller-item\"}\n data[:scroll_anchor] = \"\" if @scroll_anchor\n data[:message_id] = @message_id if @message_id\n\n {\n data: data,\n class: \"min-w-0 shrink-0\"\n }\n end\n end\nend\n" + }, + { + "path": "message_scroller_provider.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageScrollerProvider < Base\n def initialize(auto_scroll: true, previous_item_peek: 64, default_position: :end, preserve_on_prepend: true, **attrs)\n @auto_scroll = auto_scroll\n @previous_item_peek = previous_item_peek\n @default_position = default_position\n @preserve_on_prepend = preserve_on_prepend\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n slot: \"message-scroller-provider\",\n controller: \"ruby-ui--message-scroller\",\n ruby_ui__message_scroller_auto_scroll_value: @auto_scroll.to_s,\n ruby_ui__message_scroller_previous_item_peek_value: @previous_item_peek,\n ruby_ui__message_scroller_default_position_value: @default_position,\n ruby_ui__message_scroller_preserve_on_prepend_value: @preserve_on_prepend.to_s\n },\n # display: contents — the provider owns scroll state without adding a box.\n class: \"contents\"\n }\n end\n end\nend\n" + }, + { + "path": "message_scroller_viewport.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageScrollerViewport < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n tabindex: \"0\",\n data: {\n slot: \"message-scroller-viewport\",\n ruby_ui__message_scroller_target: \"viewport\"\n },\n class: \"size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain contain-content\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [ + "Message", + "Bubble" + ], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component MessageScroller", + "docs_markdown": "# Message Scroller\n\nA chat scroll container that anchors turns, follows streamed responses, preserves position when older messages load, and jumps to the latest message.\n\n## Usage\n\n### Streaming chat\n\n```ruby\nturns = [\n {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.\"},\n {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.\"},\n {role: :user, name: \"ME\", text: \"But when someone sends a new message the view feels jarring, like the conversation reloads from the top.\"},\n {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.\"},\n {role: :user, name: \"ME\", text: \"And if they scrolled up to re-read an older answer?\"},\n {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.\"}\n]\n\nMessageScrollerProvider(auto_scroll: true) do\n div(class: \"h-96 w-full rounded-xl border bg-background\") do\n MessageScroller do\n MessageScrollerViewport do\n MessageScrollerContent(class: \"p-4\") do\n turns.each do |turn|\n MessageScrollerItem(scroll_anchor: turn[:role] == :user) do\n Message(align: turn[:role] == :user ? :end : :start) do\n MessageAvatar do\n Avatar(size: :sm) { AvatarFallback { turn[:name] } }\n end\n MessageContent do\n Bubble(variant: turn[:role] == :user ? :default : :muted) do\n BubbleContent { turn[:text] }\n end\n end\n end\n end\n end\n end\n end\n MessageScrollerButton()\n end\n end\nend\n```\n\n## Anchoring turns\n\n### Anchored user turn\n\n```ruby\nMessageScrollerProvider do\n div(class: \"h-80 w-full rounded-xl border bg-background\") do\n MessageScroller do\n MessageScrollerViewport do\n MessageScrollerContent(class: \"p-4\") do\n MessageScrollerItem(scroll_anchor: true) do\n Message(align: :end) do\n MessageAvatar { Avatar(size: :sm) { AvatarFallback { \"ME\" } } }\n MessageContent { Bubble { BubbleContent { \"Can you summarize the deploy?\" } } }\n end\n end\n MessageScrollerItem do\n Message do\n MessageAvatar { Avatar(size: :sm) { AvatarFallback { \"AI\" } } }\n MessageContent { Bubble(variant: :muted) { BubbleContent { \"Shipped 3 PRs: the bubble surface, the message layout, and the scroller. All green.\" } } }\n end\n end\n end\n end\n MessageScrollerButton()\n end\n end\nend\n```\n\n## Scroll commands\n\n### Jump to start or end\n\n```ruby\nMessageScrollerProvider(auto_scroll: false) do\n div(class: \"space-y-2\") do\n div(class: \"flex gap-2\") do\n Button(variant: :outline, size: :sm, data: {action: \"click->ruby-ui--message-scroller#scrollToStart\"}) { \"Jump to start\" }\n Button(variant: :outline, size: :sm, data: {action: \"click->ruby-ui--message-scroller#scrollToEnd\"}) { \"Jump to latest\" }\n end\n div(class: \"h-72 w-full rounded-xl border bg-background\") do\n MessageScroller do\n MessageScrollerViewport do\n MessageScrollerContent(class: \"p-4\") do\n 6.times do |i|\n MessageScrollerItem do\n Message(align: i.odd? ? :end : :start) do\n MessageAvatar { Avatar(size: :sm) { AvatarFallback { i.odd? ? \"ME\" : \"AI\" } } }\n MessageContent { Bubble(variant: i.odd? ? :default : :muted) { BubbleContent { \"Message number \\#{i + 1} in a longer thread.\" } } }\n end\n end\n end\n end\n end\n MessageScrollerButton()\n end\n end\n end\nend\n```", + "examples": [ + { + "title": "Streaming chat", + "code": "turns = [\n {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.\"},\n {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.\"},\n {role: :user, name: \"ME\", text: \"But when someone sends a new message the view feels jarring, like the conversation reloads from the top.\"},\n {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.\"},\n {role: :user, name: \"ME\", text: \"And if they scrolled up to re-read an older answer?\"},\n {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.\"}\n]\n\nMessageScrollerProvider(auto_scroll: true) do\n div(class: \"h-96 w-full rounded-xl border bg-background\") do\n MessageScroller do\n MessageScrollerViewport do\n MessageScrollerContent(class: \"p-4\") do\n turns.each do |turn|\n MessageScrollerItem(scroll_anchor: turn[:role] == :user) do\n Message(align: turn[:role] == :user ? :end : :start) do\n MessageAvatar do\n Avatar(size: :sm) { AvatarFallback { turn[:name] } }\n end\n MessageContent do\n Bubble(variant: turn[:role] == :user ? :default : :muted) do\n BubbleContent { turn[:text] }\n end\n end\n end\n end\n end\n end\n end\n MessageScrollerButton()\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Anchored user turn", + "code": "MessageScrollerProvider do\n div(class: \"h-80 w-full rounded-xl border bg-background\") do\n MessageScroller do\n MessageScrollerViewport do\n MessageScrollerContent(class: \"p-4\") do\n MessageScrollerItem(scroll_anchor: true) do\n Message(align: :end) do\n MessageAvatar { Avatar(size: :sm) { AvatarFallback { \"ME\" } } }\n MessageContent { Bubble { BubbleContent { \"Can you summarize the deploy?\" } } }\n end\n end\n MessageScrollerItem do\n Message do\n MessageAvatar { Avatar(size: :sm) { AvatarFallback { \"AI\" } } }\n MessageContent { Bubble(variant: :muted) { BubbleContent { \"Shipped 3 PRs: the bubble surface, the message layout, and the scroller. All green.\" } } }\n end\n end\n end\n end\n MessageScrollerButton()\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Jump to start or end", + "code": "MessageScrollerProvider(auto_scroll: false) do\n div(class: \"space-y-2\") do\n div(class: \"flex gap-2\") do\n Button(variant: :outline, size: :sm, data: {action: \"click->ruby-ui--message-scroller#scrollToStart\"}) { \"Jump to start\" }\n Button(variant: :outline, size: :sm, data: {action: \"click->ruby-ui--message-scroller#scrollToEnd\"}) { \"Jump to latest\" }\n end\n div(class: \"h-72 w-full rounded-xl border bg-background\") do\n MessageScroller do\n MessageScrollerViewport do\n MessageScrollerContent(class: \"p-4\") do\n 6.times do |i|\n MessageScrollerItem do\n Message(align: i.odd? ? :end : :start) do\n MessageAvatar { Avatar(size: :sm) { AvatarFallback { i.odd? ? \"ME\" : \"AI\" } } }\n MessageContent { Bubble(variant: i.odd? ? :default : :muted) { BubbleContent { \"Message number \\#{i + 1} in a longer thread.\" } } }\n end\n end\n end\n end\n end\n MessageScrollerButton()\n end\n end\n end\nend\n", + "language": "ruby" + } + ] + }, "native_select": { "name": "NativeSelect", "description": "A styled native HTML select element with consistent design system integration.", From 7d06583a031cc3daa78c91449fb3ecf49571b715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Tue, 30 Jun 2026 17:06:41 -0300 Subject: [PATCH 3/5] [Bug Fix] Message Scroller: address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate anchored-turn scrolling behind autoScroll/following, so a new turn never yanks a reader who scrolled up to older content (P1). - Scroll button honors data-direction: a start-direction button now jumps to the start instead of the end (renamed action jumpToEnd → jump) (P2). - Guard last-anchor opening position with hasContentTarget; the Stimulus target getter throws rather than returning undefined (P2). - Include the flex row gap in prepend preservation so the visible row no longer drifts down by one gap per history insertion (P2). - Only treat direct content children as transcript rows; markup mutated inside a message (subtree) is handled as streaming, not history (P2). Rebuilt MCP registry. --- .../ruby_ui/message_scroller_controller.js | 54 +++++++++++++++---- .../message_scroller_button.rb | 2 +- .../message_scroller_controller.js | 54 +++++++++++++++---- gem/test/ruby_ui/message_scroller_test.rb | 2 +- mcp/data/registry.json | 4 +- 5 files changed, 90 insertions(+), 26 deletions(-) diff --git a/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js b/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js index 97f2052d..8f639265 100644 --- a/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js @@ -117,14 +117,29 @@ export default class extends Controller { onMutations(records) { let appended = null; let prependedHeight = 0; + let streamed = false; + const gap = this.rowGap(); for (const record of records) { + // Text streamed into an existing row (e.g. tokens) — not a new turn. + if (record.type === "characterData") { + streamed = true; + continue; + } if (record.type !== "childList") continue; + // Only direct children of the content element are transcript rows. + // Markup inserted *inside* a message must not be mistaken for history. + if (record.target !== this.contentTarget) { + streamed = true; + continue; + } for (const node of record.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; if (record.previousSibling === null && record.nextSibling !== null) { - // Inserted above existing rows → history prepend. - prependedHeight += node.offsetHeight || 0; + // Inserted above existing rows → history prepend. Account for the + // flex row gap each prepended row introduces, or the preserved row + // drifts down by one gap per insertion. + prependedHeight += (node.offsetHeight || 0) + gap; } else { // Inserted at (or after) the end → new turn. appended = node; @@ -137,23 +152,32 @@ export default class extends Controller { this.viewportTarget.scrollTop += prependedHeight; } - if (appended) { + // Only move for new/streamed content while the reader is at the live edge. + // If they scrolled away, leave them there and let the button surface it. + const follow = this.autoScrollValue && this.following; + if (appended && follow) { const anchor = appended.matches?.("[data-scroll-anchor]") ? appended : appended.querySelector?.("[data-scroll-anchor]"); if (anchor) { this.scrollToAnchor(anchor); - } else if (this.autoScrollValue && this.following) { + } else { this.scrollToEnd(); } - } else if (this.autoScrollValue && this.following) { - // No new row — text streamed into the last row. Stay pinned. + } else if (!appended && streamed && follow) { + // Text streamed into the last row. Stay pinned. this.scrollToEnd("auto"); } this.updateButton(); } + rowGap() { + if (!this.hasContentTarget) return 0; + const value = parseFloat(getComputedStyle(this.contentTarget).rowGap); + return Number.isFinite(value) ? value : 0; + } + // --- Public scroll commands --------------------------------------------- scrollToEnd(behavior = "smooth") { @@ -184,9 +208,14 @@ export default class extends Controller { this.scrollTo(top, behavior); } - // Bound to the scroll button's click action. - jumpToEnd() { - this.scrollToEnd(); + // Bound to the scroll button's click action. Honors the button's + // data-direction so a start-direction button jumps to the start. + jump(event) { + if (event?.currentTarget?.dataset.direction === "start") { + this.scrollToStart(); + } else { + this.scrollToEnd(); + } } // --- Internals ----------------------------------------------------------- @@ -254,8 +283,11 @@ export default class extends Controller { } if (position === "last-anchor") { - const anchors = this.contentTarget?.querySelectorAll("[data-scroll-anchor]"); - const last = anchors && anchors[anchors.length - 1]; + // Stimulus' contentTarget getter throws when missing — guard explicitly. + const anchors = this.hasContentTarget + ? this.contentTarget.querySelectorAll("[data-scroll-anchor]") + : []; + const last = anchors[anchors.length - 1]; // Fall back to the end when there's no anchor, or the last turn already // fits in the viewport. if (last && last.offsetTop - this.previousItemPeekValue > 0) { diff --git a/gem/lib/ruby_ui/message_scroller/message_scroller_button.rb b/gem/lib/ruby_ui/message_scroller/message_scroller_button.rb index 6f26ba5e..d1126a86 100644 --- a/gem/lib/ruby_ui/message_scroller/message_scroller_button.rb +++ b/gem/lib/ruby_ui/message_scroller/message_scroller_button.rb @@ -29,7 +29,7 @@ def default_attrs direction: @direction, active: "false", ruby_ui__message_scroller_target: "button", - action: "click->ruby-ui--message-scroller#jumpToEnd" + action: "click->ruby-ui--message-scroller#jump" }, class: "absolute left-1/2 z-10 -translate-x-1/2 inline-flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full data-[direction=start]:[&_svg]:rotate-180" } diff --git a/gem/lib/ruby_ui/message_scroller/message_scroller_controller.js b/gem/lib/ruby_ui/message_scroller/message_scroller_controller.js index 97f2052d..8f639265 100644 --- a/gem/lib/ruby_ui/message_scroller/message_scroller_controller.js +++ b/gem/lib/ruby_ui/message_scroller/message_scroller_controller.js @@ -117,14 +117,29 @@ export default class extends Controller { onMutations(records) { let appended = null; let prependedHeight = 0; + let streamed = false; + const gap = this.rowGap(); for (const record of records) { + // Text streamed into an existing row (e.g. tokens) — not a new turn. + if (record.type === "characterData") { + streamed = true; + continue; + } if (record.type !== "childList") continue; + // Only direct children of the content element are transcript rows. + // Markup inserted *inside* a message must not be mistaken for history. + if (record.target !== this.contentTarget) { + streamed = true; + continue; + } for (const node of record.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; if (record.previousSibling === null && record.nextSibling !== null) { - // Inserted above existing rows → history prepend. - prependedHeight += node.offsetHeight || 0; + // Inserted above existing rows → history prepend. Account for the + // flex row gap each prepended row introduces, or the preserved row + // drifts down by one gap per insertion. + prependedHeight += (node.offsetHeight || 0) + gap; } else { // Inserted at (or after) the end → new turn. appended = node; @@ -137,23 +152,32 @@ export default class extends Controller { this.viewportTarget.scrollTop += prependedHeight; } - if (appended) { + // Only move for new/streamed content while the reader is at the live edge. + // If they scrolled away, leave them there and let the button surface it. + const follow = this.autoScrollValue && this.following; + if (appended && follow) { const anchor = appended.matches?.("[data-scroll-anchor]") ? appended : appended.querySelector?.("[data-scroll-anchor]"); if (anchor) { this.scrollToAnchor(anchor); - } else if (this.autoScrollValue && this.following) { + } else { this.scrollToEnd(); } - } else if (this.autoScrollValue && this.following) { - // No new row — text streamed into the last row. Stay pinned. + } else if (!appended && streamed && follow) { + // Text streamed into the last row. Stay pinned. this.scrollToEnd("auto"); } this.updateButton(); } + rowGap() { + if (!this.hasContentTarget) return 0; + const value = parseFloat(getComputedStyle(this.contentTarget).rowGap); + return Number.isFinite(value) ? value : 0; + } + // --- Public scroll commands --------------------------------------------- scrollToEnd(behavior = "smooth") { @@ -184,9 +208,14 @@ export default class extends Controller { this.scrollTo(top, behavior); } - // Bound to the scroll button's click action. - jumpToEnd() { - this.scrollToEnd(); + // Bound to the scroll button's click action. Honors the button's + // data-direction so a start-direction button jumps to the start. + jump(event) { + if (event?.currentTarget?.dataset.direction === "start") { + this.scrollToStart(); + } else { + this.scrollToEnd(); + } } // --- Internals ----------------------------------------------------------- @@ -254,8 +283,11 @@ export default class extends Controller { } if (position === "last-anchor") { - const anchors = this.contentTarget?.querySelectorAll("[data-scroll-anchor]"); - const last = anchors && anchors[anchors.length - 1]; + // Stimulus' contentTarget getter throws when missing — guard explicitly. + const anchors = this.hasContentTarget + ? this.contentTarget.querySelectorAll("[data-scroll-anchor]") + : []; + const last = anchors[anchors.length - 1]; // Fall back to the end when there's no anchor, or the last turn already // fits in the viewport. if (last && last.offsetTop - this.previousItemPeekValue > 0) { diff --git a/gem/test/ruby_ui/message_scroller_test.rb b/gem/test/ruby_ui/message_scroller_test.rb index dbfce3a3..ed5fae25 100644 --- a/gem/test/ruby_ui/message_scroller_test.rb +++ b/gem/test/ruby_ui/message_scroller_test.rb @@ -70,7 +70,7 @@ def test_button_targets_and_action output = phlex { RubyUI.MessageScrollerButton } assert_match(/message-scroller-target="button"/, output) - assert_match(/click->ruby-ui--message-scroller#jumpToEnd/, output) + assert_match(/click->ruby-ui--message-scroller#jump/, output) assert_match(/data-direction="end"/, output) assert_match(/Scroll to end/, output) end diff --git a/mcp/data/registry.json b/mcp/data/registry.json index 54732158..41f2f6b3 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -1828,7 +1828,7 @@ }, { "path": "message_scroller_button.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageScrollerButton < Base\n def initialize(direction: :end, **attrs)\n @direction = direction\n super(**attrs)\n end\n\n def view_template(&)\n button(**attrs) do\n if block_given?\n yield\n else\n default_icon\n span(class: \"sr-only\") { (@direction == :start) ? \"Scroll to start\" : \"Scroll to end\" }\n end\n end\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n tabindex: \"-1\",\n data: {\n slot: \"message-scroller-button\",\n direction: @direction,\n active: \"false\",\n ruby_ui__message_scroller_target: \"button\",\n action: \"click->ruby-ui--message-scroller#jumpToEnd\"\n },\n class: \"absolute left-1/2 z-10 -translate-x-1/2 inline-flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full data-[direction=start]:[&_svg]:rotate-180\"\n }\n end\n\n def default_icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"size-4\"\n ) do |s|\n s.path(d: \"M12 5v14\")\n s.path(d: \"m19 12-7 7-7-7\")\n end\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageScrollerButton < Base\n def initialize(direction: :end, **attrs)\n @direction = direction\n super(**attrs)\n end\n\n def view_template(&)\n button(**attrs) do\n if block_given?\n yield\n else\n default_icon\n span(class: \"sr-only\") { (@direction == :start) ? \"Scroll to start\" : \"Scroll to end\" }\n end\n end\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n tabindex: \"-1\",\n data: {\n slot: \"message-scroller-button\",\n direction: @direction,\n active: \"false\",\n ruby_ui__message_scroller_target: \"button\",\n action: \"click->ruby-ui--message-scroller#jump\"\n },\n class: \"absolute left-1/2 z-10 -translate-x-1/2 inline-flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full data-[direction=start]:[&_svg]:rotate-180\"\n }\n end\n\n def default_icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"size-4\"\n ) do |s|\n s.path(d: \"M12 5v14\")\n s.path(d: \"m19 12-7 7-7-7\")\n end\n end\n end\nend\n" }, { "path": "message_scroller_content.rb", @@ -1836,7 +1836,7 @@ }, { "path": "message_scroller_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--message-scroller\"\n//\n// A chat transcript scroller. Owns scroll state and behavior for a\n// height-constrained message list:\n//\n// - autoScroll: follows the live edge while the reader is pinned to the\n// bottom, and releases the moment they scroll, wheel, drag, or key away.\n// - scrollAnchor: when a new anchored turn is appended, settles it near the\n// top of the viewport keeping a peek of the previous turn above it.\n// - defaultScrollPosition: where a freshly mounted transcript opens\n// (\"end\", \"start\" or \"last-anchor\").\n// - preserveScrollOnPrepend: keeps the visible row fixed when older messages\n// are loaded in above the current view.\n//\n// Public API (callable from other controllers/outlets or future\n// streaming/ActionCable code): scrollToEnd(), scrollToStart(),\n// scrollToMessage(id). New rows appended to the content target are picked up\n// automatically via MutationObserver — no manual call needed.\nexport default class extends Controller {\n static targets = [\"viewport\", \"content\", \"button\"];\n\n static values = {\n autoScroll: { type: Boolean, default: true },\n previousItemPeek: { type: Number, default: 64 },\n defaultPosition: { type: String, default: \"end\" },\n preserveOnPrepend: { type: Boolean, default: true },\n endThreshold: { type: Number, default: 32 },\n };\n\n connect() {\n // Reader is considered \"following\" the live edge until they move away.\n this.following = true;\n // True only while a programmatic scroll is in flight, so reader-intent\n // handlers don't mistake our own scrolling for the reader's.\n this.programmatic = false;\n\n this.onScroll = this.onScroll.bind(this);\n this.onWheel = this.onWheel.bind(this);\n this.onTouchStart = this.onTouchStart.bind(this);\n this.onKeydown = this.onKeydown.bind(this);\n\n if (this.hasViewportTarget) {\n this.viewportTarget.addEventListener(\"scroll\", this.onScroll, { passive: true });\n this.viewportTarget.addEventListener(\"wheel\", this.onWheel, { passive: true });\n this.viewportTarget.addEventListener(\"touchstart\", this.onTouchStart, { passive: true });\n this.viewportTarget.addEventListener(\"keydown\", this.onKeydown);\n }\n\n if (this.hasContentTarget) {\n // Announce streamed/added messages to assistive tech at a calm pace.\n if (!this.contentTarget.hasAttribute(\"role\")) {\n this.contentTarget.setAttribute(\"role\", \"log\");\n }\n if (!this.contentTarget.hasAttribute(\"aria-relevant\")) {\n this.contentTarget.setAttribute(\"aria-relevant\", \"additions text\");\n }\n\n this.observer = new MutationObserver((records) => this.onMutations(records));\n this.observer.observe(this.contentTarget, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n\n // Apply the opening position after layout settles.\n requestAnimationFrame(() => {\n this.applyDefaultPosition();\n this.updateButton();\n });\n }\n\n disconnect() {\n if (this.hasViewportTarget) {\n this.viewportTarget.removeEventListener(\"scroll\", this.onScroll);\n this.viewportTarget.removeEventListener(\"wheel\", this.onWheel);\n this.viewportTarget.removeEventListener(\"touchstart\", this.onTouchStart);\n this.viewportTarget.removeEventListener(\"keydown\", this.onKeydown);\n }\n this.observer?.disconnect();\n if (this.animationFrame) cancelAnimationFrame(this.animationFrame);\n }\n\n // --- Reader intent -------------------------------------------------------\n\n onScroll() {\n if (this.programmatic) return;\n this.following = this.isAtEnd();\n this.updateButton();\n }\n\n // Any upward wheel is a deliberate move away from the live edge.\n onWheel(event) {\n if (event.deltaY < 0) this.release();\n }\n\n onTouchStart() {\n // A touch that turns into an upward drag surfaces through onScroll; this\n // just makes the release feel immediate when the reader grabs the list.\n if (!this.isAtEnd()) this.release();\n }\n\n onKeydown(event) {\n const navKeys = [\"ArrowUp\", \"PageUp\", \"Home\", \"ArrowDown\", \"PageDown\", \"End\", \" \"];\n if (navKeys.includes(event.key)) this.release();\n }\n\n release() {\n if (this.programmatic) return;\n this.following = false;\n }\n\n // --- Mutations (new / prepended / streamed rows) -------------------------\n\n onMutations(records) {\n let appended = null;\n let prependedHeight = 0;\n\n for (const record of records) {\n if (record.type !== \"childList\") continue;\n for (const node of record.addedNodes) {\n if (node.nodeType !== Node.ELEMENT_NODE) continue;\n if (record.previousSibling === null && record.nextSibling !== null) {\n // Inserted above existing rows → history prepend.\n prependedHeight += node.offsetHeight || 0;\n } else {\n // Inserted at (or after) the end → new turn.\n appended = node;\n }\n }\n }\n\n if (prependedHeight > 0 && this.preserveOnPrependValue) {\n // Keep the reader's current row fixed while history loads in above.\n this.viewportTarget.scrollTop += prependedHeight;\n }\n\n if (appended) {\n const anchor = appended.matches?.(\"[data-scroll-anchor]\")\n ? appended\n : appended.querySelector?.(\"[data-scroll-anchor]\");\n if (anchor) {\n this.scrollToAnchor(anchor);\n } else if (this.autoScrollValue && this.following) {\n this.scrollToEnd();\n }\n } else if (this.autoScrollValue && this.following) {\n // No new row — text streamed into the last row. Stay pinned.\n this.scrollToEnd(\"auto\");\n }\n\n this.updateButton();\n }\n\n // --- Public scroll commands ---------------------------------------------\n\n scrollToEnd(behavior = \"smooth\") {\n if (!this.hasViewportTarget) return;\n this.following = true;\n this.scrollTo(this.viewportTarget.scrollHeight, behavior);\n }\n\n scrollToStart(behavior = \"smooth\") {\n if (!this.hasViewportTarget) return;\n this.following = false;\n this.scrollTo(0, behavior);\n }\n\n // Scroll a row with a matching messageId into view. Returns false when the\n // target is not mounted.\n scrollToMessage(id, behavior = \"smooth\") {\n if (!this.hasContentTarget) return false;\n const item = this.contentTarget.querySelector(`[data-message-id=\"${CSS.escape(id)}\"]`);\n if (!item) return false;\n this.following = false;\n this.scrollToAnchor(item, behavior);\n return true;\n }\n\n scrollToAnchor(item, behavior = \"smooth\") {\n const top = Math.max(0, item.offsetTop - this.previousItemPeekValue);\n this.scrollTo(top, behavior);\n }\n\n // Bound to the scroll button's click action.\n jumpToEnd() {\n this.scrollToEnd();\n }\n\n // --- Internals -----------------------------------------------------------\n\n // Native scrollTo({ behavior: \"smooth\" }) is unreliable on a contained,\n // virtualized viewport, so we animate scrollTop ourselves with rAF. This\n // gives us full control over completion (no scrollend dependency) and lets\n // us honor reduced-motion.\n scrollTo(top, behavior = \"smooth\") {\n if (!this.hasViewportTarget) return;\n const max = this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight;\n const target = Math.max(0, Math.min(top, max));\n\n this.programmatic = true;\n this.element.setAttribute(\"data-autoscrolling\", \"\");\n this.viewportTarget.setAttribute(\"data-autoscrolling\", \"\");\n if (this.animationFrame) cancelAnimationFrame(this.animationFrame);\n\n if (behavior === \"auto\" || this.prefersReducedMotion()) {\n this.viewportTarget.scrollTop = target;\n this.finishScroll();\n return;\n }\n\n const start = this.viewportTarget.scrollTop;\n const distance = target - start;\n const duration = 300;\n let startTime = null;\n\n const step = (now) => {\n if (startTime === null) startTime = now;\n const t = Math.min(1, (now - startTime) / duration);\n // easeOutCubic\n const eased = 1 - Math.pow(1 - t, 3);\n this.viewportTarget.scrollTop = start + distance * eased;\n if (t < 1) {\n this.animationFrame = requestAnimationFrame(step);\n } else {\n this.finishScroll();\n }\n };\n this.animationFrame = requestAnimationFrame(step);\n }\n\n finishScroll() {\n this.programmatic = false;\n this.element.removeAttribute(\"data-autoscrolling\");\n this.viewportTarget?.removeAttribute(\"data-autoscrolling\");\n this.following = this.isAtEnd();\n this.updateButton();\n }\n\n prefersReducedMotion() {\n return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n }\n\n applyDefaultPosition() {\n if (!this.hasViewportTarget) return;\n const position = this.defaultPositionValue;\n\n if (position === \"start\") {\n this.following = false;\n this.viewportTarget.scrollTop = 0;\n return;\n }\n\n if (position === \"last-anchor\") {\n const anchors = this.contentTarget?.querySelectorAll(\"[data-scroll-anchor]\");\n const last = anchors && anchors[anchors.length - 1];\n // Fall back to the end when there's no anchor, or the last turn already\n // fits in the viewport.\n if (last && last.offsetTop - this.previousItemPeekValue > 0) {\n this.following = false;\n this.viewportTarget.scrollTop = Math.max(0, last.offsetTop - this.previousItemPeekValue);\n this.updateButton();\n return;\n }\n }\n\n // Default: open at the live edge.\n this.following = true;\n this.viewportTarget.scrollTop = this.viewportTarget.scrollHeight;\n }\n\n isAtEnd() {\n if (!this.hasViewportTarget) return true;\n const { scrollTop, clientHeight, scrollHeight } = this.viewportTarget;\n return scrollHeight - (scrollTop + clientHeight) <= this.endThresholdValue;\n }\n\n hasOverflow() {\n if (!this.hasViewportTarget) return false;\n return this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight > this.endThresholdValue;\n }\n\n updateButton() {\n if (!this.hasButtonTarget) return;\n const active = this.hasOverflow() && !this.isAtEnd();\n this.buttonTarget.setAttribute(\"data-active\", active ? \"true\" : \"false\");\n // Remove the inert button from the tab order so there are no ghost stops.\n this.buttonTarget.setAttribute(\"tabindex\", active ? \"0\" : \"-1\");\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--message-scroller\"\n//\n// A chat transcript scroller. Owns scroll state and behavior for a\n// height-constrained message list:\n//\n// - autoScroll: follows the live edge while the reader is pinned to the\n// bottom, and releases the moment they scroll, wheel, drag, or key away.\n// - scrollAnchor: when a new anchored turn is appended, settles it near the\n// top of the viewport keeping a peek of the previous turn above it.\n// - defaultScrollPosition: where a freshly mounted transcript opens\n// (\"end\", \"start\" or \"last-anchor\").\n// - preserveScrollOnPrepend: keeps the visible row fixed when older messages\n// are loaded in above the current view.\n//\n// Public API (callable from other controllers/outlets or future\n// streaming/ActionCable code): scrollToEnd(), scrollToStart(),\n// scrollToMessage(id). New rows appended to the content target are picked up\n// automatically via MutationObserver — no manual call needed.\nexport default class extends Controller {\n static targets = [\"viewport\", \"content\", \"button\"];\n\n static values = {\n autoScroll: { type: Boolean, default: true },\n previousItemPeek: { type: Number, default: 64 },\n defaultPosition: { type: String, default: \"end\" },\n preserveOnPrepend: { type: Boolean, default: true },\n endThreshold: { type: Number, default: 32 },\n };\n\n connect() {\n // Reader is considered \"following\" the live edge until they move away.\n this.following = true;\n // True only while a programmatic scroll is in flight, so reader-intent\n // handlers don't mistake our own scrolling for the reader's.\n this.programmatic = false;\n\n this.onScroll = this.onScroll.bind(this);\n this.onWheel = this.onWheel.bind(this);\n this.onTouchStart = this.onTouchStart.bind(this);\n this.onKeydown = this.onKeydown.bind(this);\n\n if (this.hasViewportTarget) {\n this.viewportTarget.addEventListener(\"scroll\", this.onScroll, { passive: true });\n this.viewportTarget.addEventListener(\"wheel\", this.onWheel, { passive: true });\n this.viewportTarget.addEventListener(\"touchstart\", this.onTouchStart, { passive: true });\n this.viewportTarget.addEventListener(\"keydown\", this.onKeydown);\n }\n\n if (this.hasContentTarget) {\n // Announce streamed/added messages to assistive tech at a calm pace.\n if (!this.contentTarget.hasAttribute(\"role\")) {\n this.contentTarget.setAttribute(\"role\", \"log\");\n }\n if (!this.contentTarget.hasAttribute(\"aria-relevant\")) {\n this.contentTarget.setAttribute(\"aria-relevant\", \"additions text\");\n }\n\n this.observer = new MutationObserver((records) => this.onMutations(records));\n this.observer.observe(this.contentTarget, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n\n // Apply the opening position after layout settles.\n requestAnimationFrame(() => {\n this.applyDefaultPosition();\n this.updateButton();\n });\n }\n\n disconnect() {\n if (this.hasViewportTarget) {\n this.viewportTarget.removeEventListener(\"scroll\", this.onScroll);\n this.viewportTarget.removeEventListener(\"wheel\", this.onWheel);\n this.viewportTarget.removeEventListener(\"touchstart\", this.onTouchStart);\n this.viewportTarget.removeEventListener(\"keydown\", this.onKeydown);\n }\n this.observer?.disconnect();\n if (this.animationFrame) cancelAnimationFrame(this.animationFrame);\n }\n\n // --- Reader intent -------------------------------------------------------\n\n onScroll() {\n if (this.programmatic) return;\n this.following = this.isAtEnd();\n this.updateButton();\n }\n\n // Any upward wheel is a deliberate move away from the live edge.\n onWheel(event) {\n if (event.deltaY < 0) this.release();\n }\n\n onTouchStart() {\n // A touch that turns into an upward drag surfaces through onScroll; this\n // just makes the release feel immediate when the reader grabs the list.\n if (!this.isAtEnd()) this.release();\n }\n\n onKeydown(event) {\n const navKeys = [\"ArrowUp\", \"PageUp\", \"Home\", \"ArrowDown\", \"PageDown\", \"End\", \" \"];\n if (navKeys.includes(event.key)) this.release();\n }\n\n release() {\n if (this.programmatic) return;\n this.following = false;\n }\n\n // --- Mutations (new / prepended / streamed rows) -------------------------\n\n onMutations(records) {\n let appended = null;\n let prependedHeight = 0;\n let streamed = false;\n const gap = this.rowGap();\n\n for (const record of records) {\n // Text streamed into an existing row (e.g. tokens) — not a new turn.\n if (record.type === \"characterData\") {\n streamed = true;\n continue;\n }\n if (record.type !== \"childList\") continue;\n // Only direct children of the content element are transcript rows.\n // Markup inserted *inside* a message must not be mistaken for history.\n if (record.target !== this.contentTarget) {\n streamed = true;\n continue;\n }\n for (const node of record.addedNodes) {\n if (node.nodeType !== Node.ELEMENT_NODE) continue;\n if (record.previousSibling === null && record.nextSibling !== null) {\n // Inserted above existing rows → history prepend. Account for the\n // flex row gap each prepended row introduces, or the preserved row\n // drifts down by one gap per insertion.\n prependedHeight += (node.offsetHeight || 0) + gap;\n } else {\n // Inserted at (or after) the end → new turn.\n appended = node;\n }\n }\n }\n\n if (prependedHeight > 0 && this.preserveOnPrependValue) {\n // Keep the reader's current row fixed while history loads in above.\n this.viewportTarget.scrollTop += prependedHeight;\n }\n\n // Only move for new/streamed content while the reader is at the live edge.\n // If they scrolled away, leave them there and let the button surface it.\n const follow = this.autoScrollValue && this.following;\n if (appended && follow) {\n const anchor = appended.matches?.(\"[data-scroll-anchor]\")\n ? appended\n : appended.querySelector?.(\"[data-scroll-anchor]\");\n if (anchor) {\n this.scrollToAnchor(anchor);\n } else {\n this.scrollToEnd();\n }\n } else if (!appended && streamed && follow) {\n // Text streamed into the last row. Stay pinned.\n this.scrollToEnd(\"auto\");\n }\n\n this.updateButton();\n }\n\n rowGap() {\n if (!this.hasContentTarget) return 0;\n const value = parseFloat(getComputedStyle(this.contentTarget).rowGap);\n return Number.isFinite(value) ? value : 0;\n }\n\n // --- Public scroll commands ---------------------------------------------\n\n scrollToEnd(behavior = \"smooth\") {\n if (!this.hasViewportTarget) return;\n this.following = true;\n this.scrollTo(this.viewportTarget.scrollHeight, behavior);\n }\n\n scrollToStart(behavior = \"smooth\") {\n if (!this.hasViewportTarget) return;\n this.following = false;\n this.scrollTo(0, behavior);\n }\n\n // Scroll a row with a matching messageId into view. Returns false when the\n // target is not mounted.\n scrollToMessage(id, behavior = \"smooth\") {\n if (!this.hasContentTarget) return false;\n const item = this.contentTarget.querySelector(`[data-message-id=\"${CSS.escape(id)}\"]`);\n if (!item) return false;\n this.following = false;\n this.scrollToAnchor(item, behavior);\n return true;\n }\n\n scrollToAnchor(item, behavior = \"smooth\") {\n const top = Math.max(0, item.offsetTop - this.previousItemPeekValue);\n this.scrollTo(top, behavior);\n }\n\n // Bound to the scroll button's click action. Honors the button's\n // data-direction so a start-direction button jumps to the start.\n jump(event) {\n if (event?.currentTarget?.dataset.direction === \"start\") {\n this.scrollToStart();\n } else {\n this.scrollToEnd();\n }\n }\n\n // --- Internals -----------------------------------------------------------\n\n // Native scrollTo({ behavior: \"smooth\" }) is unreliable on a contained,\n // virtualized viewport, so we animate scrollTop ourselves with rAF. This\n // gives us full control over completion (no scrollend dependency) and lets\n // us honor reduced-motion.\n scrollTo(top, behavior = \"smooth\") {\n if (!this.hasViewportTarget) return;\n const max = this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight;\n const target = Math.max(0, Math.min(top, max));\n\n this.programmatic = true;\n this.element.setAttribute(\"data-autoscrolling\", \"\");\n this.viewportTarget.setAttribute(\"data-autoscrolling\", \"\");\n if (this.animationFrame) cancelAnimationFrame(this.animationFrame);\n\n if (behavior === \"auto\" || this.prefersReducedMotion()) {\n this.viewportTarget.scrollTop = target;\n this.finishScroll();\n return;\n }\n\n const start = this.viewportTarget.scrollTop;\n const distance = target - start;\n const duration = 300;\n let startTime = null;\n\n const step = (now) => {\n if (startTime === null) startTime = now;\n const t = Math.min(1, (now - startTime) / duration);\n // easeOutCubic\n const eased = 1 - Math.pow(1 - t, 3);\n this.viewportTarget.scrollTop = start + distance * eased;\n if (t < 1) {\n this.animationFrame = requestAnimationFrame(step);\n } else {\n this.finishScroll();\n }\n };\n this.animationFrame = requestAnimationFrame(step);\n }\n\n finishScroll() {\n this.programmatic = false;\n this.element.removeAttribute(\"data-autoscrolling\");\n this.viewportTarget?.removeAttribute(\"data-autoscrolling\");\n this.following = this.isAtEnd();\n this.updateButton();\n }\n\n prefersReducedMotion() {\n return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n }\n\n applyDefaultPosition() {\n if (!this.hasViewportTarget) return;\n const position = this.defaultPositionValue;\n\n if (position === \"start\") {\n this.following = false;\n this.viewportTarget.scrollTop = 0;\n return;\n }\n\n if (position === \"last-anchor\") {\n // Stimulus' contentTarget getter throws when missing — guard explicitly.\n const anchors = this.hasContentTarget\n ? this.contentTarget.querySelectorAll(\"[data-scroll-anchor]\")\n : [];\n const last = anchors[anchors.length - 1];\n // Fall back to the end when there's no anchor, or the last turn already\n // fits in the viewport.\n if (last && last.offsetTop - this.previousItemPeekValue > 0) {\n this.following = false;\n this.viewportTarget.scrollTop = Math.max(0, last.offsetTop - this.previousItemPeekValue);\n this.updateButton();\n return;\n }\n }\n\n // Default: open at the live edge.\n this.following = true;\n this.viewportTarget.scrollTop = this.viewportTarget.scrollHeight;\n }\n\n isAtEnd() {\n if (!this.hasViewportTarget) return true;\n const { scrollTop, clientHeight, scrollHeight } = this.viewportTarget;\n return scrollHeight - (scrollTop + clientHeight) <= this.endThresholdValue;\n }\n\n hasOverflow() {\n if (!this.hasViewportTarget) return false;\n return this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight > this.endThresholdValue;\n }\n\n updateButton() {\n if (!this.hasButtonTarget) return;\n const active = this.hasOverflow() && !this.isAtEnd();\n this.buttonTarget.setAttribute(\"data-active\", active ? \"true\" : \"false\");\n // Remove the inert button from the tab order so there are no ghost stops.\n this.buttonTarget.setAttribute(\"tabindex\", active ? \"0\" : \"-1\");\n }\n}\n" }, { "path": "message_scroller_item.rb", From fb46ef42eb66ec74f5f5ac9f9a8b96a0a6aae02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Tue, 30 Jun 2026 17:47:06 -0300 Subject: [PATCH 4/5] [Documentation] Message Scroller: faithful chat-window demo with Empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/app/javascript/controllers/index.js | 3 + .../message_scroller_chat_controller.js | 61 ++++++++++++++ docs/app/views/docs/message_scroller.rb | 83 +++++++++++++++++++ .../message_scroller/message_scroller_docs.rb | 83 +++++++++++++++++++ mcp/data/registry.json | 7 +- 5 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 docs/app/javascript/controllers/message_scroller_chat_controller.js diff --git a/docs/app/javascript/controllers/index.js b/docs/app/javascript/controllers/index.js index 9ee53579..b784baff 100644 --- a/docs/app/javascript/controllers/index.js +++ b/docs/app/javascript/controllers/index.js @@ -7,6 +7,9 @@ import { application } from "./application" import IframeThemeController from "./iframe_theme_controller" application.register("iframe-theme", IframeThemeController) +import MessageScrollerChatController from "./message_scroller_chat_controller" +application.register("message-scroller-chat", MessageScrollerChatController) + import RubyUi__AccordionController from "./ruby_ui/accordion_controller" application.register("ruby-ui--accordion", RubyUi__AccordionController) diff --git a/docs/app/javascript/controllers/message_scroller_chat_controller.js b/docs/app/javascript/controllers/message_scroller_chat_controller.js new file mode 100644 index 00000000..52c51941 --- /dev/null +++ b/docs/app/javascript/controllers/message_scroller_chat_controller.js @@ -0,0 +1,61 @@ +import { Controller } from "@hotwired/stimulus"; + +// Docs-only demo harness for the Message Scroller chat window. +// +// On submit it clones the server-rendered user/assistant