diff --git a/docs/app/components/shared/components_list.rb b/docs/app/components/shared/components_list.rb index 60be2224..eae5c84d 100644 --- a/docs/app/components/shared/components_list.rb +++ b/docs/app/components/shared/components_list.rb @@ -12,6 +12,7 @@ def components {name: "Avatar", path: docs_avatar_path}, {name: "Badge", path: docs_badge_path}, {name: "Breadcrumb", path: docs_breadcrumb_path}, + {name: "Bubble", path: docs_bubble_path}, {name: "Button", path: docs_button_path}, {name: "Calendar", path: docs_calendar_path}, {name: "Card", path: docs_card_path}, @@ -34,6 +35,7 @@ def components {name: "Input", path: docs_input_path}, {name: "Link", path: docs_link_path}, {name: "Masked Input", path: masked_input_path}, + {name: "Message", path: docs_message_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 ce1dc881..d5e8473d 100644 --- a/docs/app/controllers/docs_controller.rb +++ b/docs/app/controllers/docs_controller.rb @@ -74,6 +74,10 @@ def breadcrumb render Views::Docs::Breadcrumb.new end + def bubble + render Views::Docs::Bubble.new + end + def button render Views::Docs::Button.new end @@ -162,6 +166,10 @@ def masked_input render Views::Docs::MaskedInput.new end + def message + render Views::Docs::Message.new + end + def pagination render Views::Docs::Pagination.new end diff --git a/docs/app/lib/site_files.rb b/docs/app/lib/site_files.rb index f9c6d3f8..b0984020 100644 --- a/docs/app/lib/site_files.rb +++ b/docs/app/lib/site_files.rb @@ -86,6 +86,7 @@ class SiteFiles {title: "Avatar", path: "/docs/avatar", description: "Image and fallback primitives for representing a user."}, {title: "Badge", path: "/docs/badge", description: "Small status or label element."}, {title: "Breadcrumb", path: "/docs/breadcrumb", description: "Navigation trail showing the current location in a hierarchy."}, + {title: "Bubble", path: "/docs/bubble", description: "Chat bubble surface with variants, alignment, grouping, and reactions."}, {title: "Button", path: "/docs/button", description: "Button component and button-like variants."}, {title: "Calendar", path: "/docs/calendar", description: "Date field component for entering and editing dates."}, {title: "Card", path: "/docs/card", description: "Content container with header, content, and footer primitives."}, @@ -107,6 +108,7 @@ class SiteFiles {title: "Input", path: "/docs/input", description: "Styled input field primitive."}, {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: "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/bubble.rb b/docs/app/views/docs/bubble.rb new file mode 100644 index 00000000..d447ebc7 --- /dev/null +++ b/docs/app/views/docs/bubble.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +class Views::Docs::Bubble < Views::Base + def view_template + component = "Bubble" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new(title: "Bubble", description: "A chat bubble surface for displaying conversational content, with variants, alignment, grouping, and reactions.") + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Default", context: self) do + <<~RUBY + Bubble(align: :end) do + BubbleContent { "Hey there! what's up?" } + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Conversation", context: self) do + <<~RUBY + div(class: "flex flex-col gap-8") do + Bubble(align: :end) do + BubbleContent { "Hey there! what's up?" } + end + BubbleGroup do + Bubble(variant: :muted) do + BubbleContent { "Hey! Want to see chat bubbles?" } + end + Bubble(variant: :muted) do + BubbleContent { "I can group messages, switch sides, and keep the whole thread easy to scan." } + BubbleReactions(role: "img", aria_label: "Reaction: thumbs up") do + span { "👍" } + end + end + end + Bubble(align: :end) do + BubbleContent { "Sure. Hit me with your best demo." } + end + end + RUBY + end + + Heading(level: 2) { "Variants" } + + render Docs::VisualCodeExample.new(title: "Variants", context: self) do + <<~RUBY + div(class: "flex flex-col gap-4 w-full") do + Bubble(variant: :default) { BubbleContent { "Default" } } + Bubble(variant: :secondary) { BubbleContent { "Secondary" } } + Bubble(variant: :muted) { BubbleContent { "Muted" } } + Bubble(variant: :tinted) { BubbleContent { "Tinted" } } + Bubble(variant: :outline) { BubbleContent { "Outline" } } + Bubble(variant: :ghost) { BubbleContent { "Ghost — unframed, full width for assistant text or markdown." } } + Bubble(variant: :destructive) { BubbleContent { "Destructive — something went wrong." } } + end + RUBY + end + + Heading(level: 2) { "Alignment" } + + render Docs::VisualCodeExample.new(title: "Start and end", context: self) do + <<~RUBY + div(class: "flex flex-col gap-4 w-full") do + Bubble(align: :start, variant: :muted) do + BubbleContent { "Aligned to the start (receiver)." } + end + Bubble(align: :end) do + BubbleContent { "Aligned to the end (sender)." } + end + end + RUBY + end + + Heading(level: 2) { "Reactions" } + + render Docs::VisualCodeExample.new(title: "Reactions", context: self) do + <<~RUBY + div(class: "flex flex-col gap-10 w-full py-6") do + Bubble(variant: :muted) do + BubbleContent { "Reactions anchor to the bubble edge." } + BubbleReactions(role: "img", aria_label: "Reactions: thumbs up, fire, eyes, and 2 more") do + span { "👍" } + span { "🔥" } + span { "👀" } + span { "+2" } + end + end + Bubble(align: :end) do + BubbleContent { "Place them on top and to the start too." } + BubbleReactions(side: :top, align: :start, role: "img", aria_label: "Reaction: heart") do + span { "❤️" } + end + end + end + RUBY + end + + Heading(level: 2) { "Group" } + + render Docs::VisualCodeExample.new(title: "Bubble group", context: self) do + <<~RUBY + BubbleGroup do + Bubble(variant: :muted) { BubbleContent { "First message in the group." } } + Bubble(variant: :muted) { BubbleContent { "Second one, tighter spacing." } } + Bubble(variant: :muted) { BubbleContent { "Third, all stacked together." } } + end + RUBY + end + + Heading(level: 2) { "Link or button bubble" } + + render Docs::VisualCodeExample.new(title: "Interactive content", context: self) do + <<~RUBY + div(class: "flex flex-col gap-4 w-full") do + Bubble(align: :end) do + BubbleContent(as: :a, href: "#") { "Tap to open the link →" } + end + Bubble(variant: :outline) do + BubbleContent(as: :button, type: "button") { "Retry sending" } + end + end + RUBY + end + + Heading(level: 2) { "With Tooltip" } + + render Docs::VisualCodeExample.new(title: "Reveal metadata on hover", context: self) do + <<~RUBY + Tooltip do + TooltipTrigger(class: "w-fit") do + Bubble(variant: :muted, class: "max-w-none") do + BubbleContent { "Read 9:41 AM" } + end + end + TooltipContent do + Text { "Delivered and read" } + end + end + RUBY + end + + Heading(level: 2) { "With Popover" } + + render Docs::VisualCodeExample.new(title: "Surface details on demand", context: self) do + <<~RUBY + Popover do + PopoverTrigger do + Bubble(variant: :destructive, class: "max-w-none") do + BubbleContent { "Message failed to send" } + end + end + PopoverContent(class: "w-64") do + Text(weight: :semibold) { "Delivery error" } + Text(size: :sm, class: "text-muted-foreground") { "The recipient's inbox is full. Try again later." } + 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/app/views/docs/message.rb b/docs/app/views/docs/message.rb new file mode 100644 index 00000000..7488396b --- /dev/null +++ b/docs/app/views/docs/message.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +class Views::Docs::Message < Views::Base + def view_template + component = "Message" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new(title: "Message", description: "A chat message layout that pairs an avatar with bubbles, headers, and footers. Built on top of Avatar and Bubble.") + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Conversation", context: self) do + <<~RUBY + div(class: "flex flex-col gap-6") do + Message(align: :end) do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me") + AvatarFallback { "ME" } + end + end + MessageContent do + Bubble do + BubbleContent { "Deploying to prod real quick." } + end + end + end + + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit") + AvatarFallback { "R" } + end + end + MessageContent do + Bubble(variant: :muted) do + BubbleContent { "It's 4:55 PM. On a Friday." } + end + end + end + + Message(align: :end) do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me") + AvatarFallback { "ME" } + end + end + MessageContent do + Bubble do + BubbleContent { "It's a one-line change." } + end + MessageFooter { "Delivered" } + end + end + + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit") + AvatarFallback { "R" } + end + end + MessageContent do + BubbleGroup do + Bubble(variant: :muted) do + BubbleContent { "It's always a one-line change 😭." } + end + Bubble(variant: :muted) do + BubbleContent { "Alright, let me take a look." } + BubbleReactions(role: "img", aria_label: "Reaction: thumbs up") do + span { "👍" } + end + end + end + end + end + end + RUBY + end + + Heading(level: 2) { "With header" } + + render Docs::VisualCodeExample.new(title: "Header and footer", context: self) do + <<~RUBY + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@oliver") + AvatarFallback { "OL" } + end + end + MessageContent do + MessageHeader { "Oliver" } + Bubble(variant: :muted) do + BubbleContent { "Pushed the fix, can you review?" } + end + MessageFooter { "9:41 AM" } + end + end + RUBY + end + + Heading(level: 2) { "Alignment" } + + render Docs::VisualCodeExample.new(title: "Sender and receiver", context: self) do + <<~RUBY + div(class: "flex flex-col gap-6") do + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit") + AvatarFallback { "R" } + end + end + MessageContent do + Bubble(variant: :muted) { BubbleContent { "Aligned to the start." } } + end + end + Message(align: :end) do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me") + AvatarFallback { "ME" } + end + end + MessageContent do + Bubble { BubbleContent { "Aligned to the end." } } + end + end + end + RUBY + end + + Heading(level: 2) { "Group" } + + render Docs::VisualCodeExample.new(title: "Message group", context: self) do + <<~RUBY + MessageGroup do + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit") + AvatarFallback { "R" } + end + end + MessageContent do + Bubble(variant: :muted) { BubbleContent { "First message." } } + end + end + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit") + AvatarFallback { "R" } + end + end + MessageContent do + Bubble(variant: :muted) { BubbleContent { "Second, tighter spacing." } } + 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 59107400..59702c7f 100644 --- a/docs/config/routes.rb +++ b/docs/config/routes.rb @@ -30,6 +30,7 @@ get "avatar", to: "docs#avatar", as: :docs_avatar get "badge", to: "docs#badge", as: :docs_badge get "breadcrumb", to: "docs#breadcrumb", as: :docs_breadcrumb + get "bubble", to: "docs#bubble", as: :docs_bubble get "button", to: "docs#button", as: :docs_button get "card", to: "docs#card", as: :docs_card get "carousel", to: "docs#carousel", as: :docs_carousel @@ -51,6 +52,7 @@ get "input", to: "docs#input", as: :docs_input 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 "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 8e0feff8..3bd2e280 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -118,6 +118,11 @@ This file expands the curated /llms.txt map into a compact reference that can be - URL: https://rubyui.com/docs/breadcrumb - Summary: Navigation trail showing the current location in a hierarchy. +### Bubble + +- URL: https://rubyui.com/docs/bubble +- Summary: Chat bubble surface with variants, alignment, grouping, and reactions. + ### Button - URL: https://rubyui.com/docs/button @@ -223,6 +228,11 @@ This file expands the curated /llms.txt map into a compact reference that can be - URL: https://rubyui.com/docs/masked_input - Summary: Form input with an applied mask. +### Message + +- URL: https://rubyui.com/docs/message +- Summary: Chat message layout pairing an avatar with bubbles, headers, and footers. + ### Pagination - URL: https://rubyui.com/docs/pagination diff --git a/docs/public/llms.txt b/docs/public/llms.txt index a02be577..722ad3ac 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -28,6 +28,7 @@ Use the core docs first for installation, theming, dark mode, and customization - [Avatar](https://rubyui.com/docs/avatar): Image and fallback primitives for representing a user. - [Badge](https://rubyui.com/docs/badge): Small status or label element. - [Breadcrumb](https://rubyui.com/docs/breadcrumb): Navigation trail showing the current location in a hierarchy. +- [Bubble](https://rubyui.com/docs/bubble): Chat bubble surface with variants, alignment, grouping, and reactions. - [Button](https://rubyui.com/docs/button): Button component and button-like variants. - [Calendar](https://rubyui.com/docs/calendar): Date field component for entering and editing dates. - [Card](https://rubyui.com/docs/card): Content container with header, content, and footer primitives. @@ -49,6 +50,7 @@ Use the core docs first for installation, theming, dark mode, and customization - [Input](https://rubyui.com/docs/input): Styled input field primitive. - [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. - [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 2cd1f293..e3f5c942 100644 --- a/docs/public/sitemap.xml +++ b/docs/public/sitemap.xml @@ -95,6 +95,11 @@ monthly 0.7 + + https://rubyui.com/docs/bubble + monthly + 0.7 + https://rubyui.com/docs/button monthly @@ -200,6 +205,11 @@ monthly 0.7 + + https://rubyui.com/docs/message + 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 3d774b00..64e71c5d 100644 --- a/gem/lib/generators/ruby_ui/dependencies.yml +++ b/gem/lib/generators/ruby_ui/dependencies.yml @@ -73,6 +73,11 @@ masked_input: js_packages: - "maska" +message: + components: + - "Avatar" + - "Bubble" + pagination: components: - "Button" diff --git a/gem/lib/ruby_ui/bubble/bubble.rb b/gem/lib/ruby_ui/bubble/bubble.rb new file mode 100644 index 00000000..85ce6c20 --- /dev/null +++ b/gem/lib/ruby_ui/bubble/bubble.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module RubyUI + class Bubble < Base + VARIANTS = { + default: "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80", + secondary: "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]", + muted: "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]", + tinted: "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]", + outline: "*:data-[slot=bubble-content]:bg-background *:data-[slot=bubble-content]:border-border [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30", + ghost: "*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none", + destructive: "*:data-[slot=bubble-content]:bg-destructive/10 dark:*:data-[slot=bubble-content]:bg-destructive/20 *:data-[slot=bubble-content]:text-destructive [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30" + } + + def initialize(variant: :default, align: :start, **attrs) + @variant = variant + @align = align + super(**attrs) + end + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "bubble", variant: @variant, align: @align}, + class: [ + "group/bubble relative flex w-fit min-w-0 flex-col gap-1 max-w-[80%] data-[align=end]:self-end data-[variant=ghost]:max-w-full", + VARIANTS[@variant] + ] + } + end + end +end diff --git a/gem/lib/ruby_ui/bubble/bubble_content.rb b/gem/lib/ruby_ui/bubble/bubble_content.rb new file mode 100644 index 00000000..b8f0bcd1 --- /dev/null +++ b/gem/lib/ruby_ui/bubble/bubble_content.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module RubyUI + class BubbleContent < Base + def initialize(as: :div, **attrs) + @as = as + super(**attrs) + end + + def view_template(&) + send(@as, **attrs, &) + end + + private + + def default_attrs + { + data: {slot: "bubble-content"}, + class: "w-fit max-w-full min-w-0 overflow-hidden wrap-break-word rounded-3xl border border-transparent px-3 py-2.5 text-sm leading-relaxed group-data-[align=end]/bubble:self-end [&:is(button,a)]:text-left [&:is(button,a)]:outline-none [&:is(button,a)]:transition-colors [&:is(button,a):focus-visible]:border-ring [&:is(button,a):focus-visible]:ring-3 [&:is(button,a):focus-visible]:ring-ring/30" + } + end + end +end diff --git a/gem/lib/ruby_ui/bubble/bubble_docs.rb b/gem/lib/ruby_ui/bubble/bubble_docs.rb new file mode 100644 index 00000000..d447ebc7 --- /dev/null +++ b/gem/lib/ruby_ui/bubble/bubble_docs.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +class Views::Docs::Bubble < Views::Base + def view_template + component = "Bubble" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new(title: "Bubble", description: "A chat bubble surface for displaying conversational content, with variants, alignment, grouping, and reactions.") + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Default", context: self) do + <<~RUBY + Bubble(align: :end) do + BubbleContent { "Hey there! what's up?" } + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Conversation", context: self) do + <<~RUBY + div(class: "flex flex-col gap-8") do + Bubble(align: :end) do + BubbleContent { "Hey there! what's up?" } + end + BubbleGroup do + Bubble(variant: :muted) do + BubbleContent { "Hey! Want to see chat bubbles?" } + end + Bubble(variant: :muted) do + BubbleContent { "I can group messages, switch sides, and keep the whole thread easy to scan." } + BubbleReactions(role: "img", aria_label: "Reaction: thumbs up") do + span { "👍" } + end + end + end + Bubble(align: :end) do + BubbleContent { "Sure. Hit me with your best demo." } + end + end + RUBY + end + + Heading(level: 2) { "Variants" } + + render Docs::VisualCodeExample.new(title: "Variants", context: self) do + <<~RUBY + div(class: "flex flex-col gap-4 w-full") do + Bubble(variant: :default) { BubbleContent { "Default" } } + Bubble(variant: :secondary) { BubbleContent { "Secondary" } } + Bubble(variant: :muted) { BubbleContent { "Muted" } } + Bubble(variant: :tinted) { BubbleContent { "Tinted" } } + Bubble(variant: :outline) { BubbleContent { "Outline" } } + Bubble(variant: :ghost) { BubbleContent { "Ghost — unframed, full width for assistant text or markdown." } } + Bubble(variant: :destructive) { BubbleContent { "Destructive — something went wrong." } } + end + RUBY + end + + Heading(level: 2) { "Alignment" } + + render Docs::VisualCodeExample.new(title: "Start and end", context: self) do + <<~RUBY + div(class: "flex flex-col gap-4 w-full") do + Bubble(align: :start, variant: :muted) do + BubbleContent { "Aligned to the start (receiver)." } + end + Bubble(align: :end) do + BubbleContent { "Aligned to the end (sender)." } + end + end + RUBY + end + + Heading(level: 2) { "Reactions" } + + render Docs::VisualCodeExample.new(title: "Reactions", context: self) do + <<~RUBY + div(class: "flex flex-col gap-10 w-full py-6") do + Bubble(variant: :muted) do + BubbleContent { "Reactions anchor to the bubble edge." } + BubbleReactions(role: "img", aria_label: "Reactions: thumbs up, fire, eyes, and 2 more") do + span { "👍" } + span { "🔥" } + span { "👀" } + span { "+2" } + end + end + Bubble(align: :end) do + BubbleContent { "Place them on top and to the start too." } + BubbleReactions(side: :top, align: :start, role: "img", aria_label: "Reaction: heart") do + span { "❤️" } + end + end + end + RUBY + end + + Heading(level: 2) { "Group" } + + render Docs::VisualCodeExample.new(title: "Bubble group", context: self) do + <<~RUBY + BubbleGroup do + Bubble(variant: :muted) { BubbleContent { "First message in the group." } } + Bubble(variant: :muted) { BubbleContent { "Second one, tighter spacing." } } + Bubble(variant: :muted) { BubbleContent { "Third, all stacked together." } } + end + RUBY + end + + Heading(level: 2) { "Link or button bubble" } + + render Docs::VisualCodeExample.new(title: "Interactive content", context: self) do + <<~RUBY + div(class: "flex flex-col gap-4 w-full") do + Bubble(align: :end) do + BubbleContent(as: :a, href: "#") { "Tap to open the link →" } + end + Bubble(variant: :outline) do + BubbleContent(as: :button, type: "button") { "Retry sending" } + end + end + RUBY + end + + Heading(level: 2) { "With Tooltip" } + + render Docs::VisualCodeExample.new(title: "Reveal metadata on hover", context: self) do + <<~RUBY + Tooltip do + TooltipTrigger(class: "w-fit") do + Bubble(variant: :muted, class: "max-w-none") do + BubbleContent { "Read 9:41 AM" } + end + end + TooltipContent do + Text { "Delivered and read" } + end + end + RUBY + end + + Heading(level: 2) { "With Popover" } + + render Docs::VisualCodeExample.new(title: "Surface details on demand", context: self) do + <<~RUBY + Popover do + PopoverTrigger do + Bubble(variant: :destructive, class: "max-w-none") do + BubbleContent { "Message failed to send" } + end + end + PopoverContent(class: "w-64") do + Text(weight: :semibold) { "Delivery error" } + Text(size: :sm, class: "text-muted-foreground") { "The recipient's inbox is full. Try again later." } + 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/bubble/bubble_group.rb b/gem/lib/ruby_ui/bubble/bubble_group.rb new file mode 100644 index 00000000..0324751b --- /dev/null +++ b/gem/lib/ruby_ui/bubble/bubble_group.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class BubbleGroup < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "bubble-group"}, + class: "flex min-w-0 flex-col gap-2" + } + end + end +end diff --git a/gem/lib/ruby_ui/bubble/bubble_reactions.rb b/gem/lib/ruby_ui/bubble/bubble_reactions.rb new file mode 100644 index 00000000..4e652aed --- /dev/null +++ b/gem/lib/ruby_ui/bubble/bubble_reactions.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module RubyUI + class BubbleReactions < Base + SIDES = { + top: "top-0 -translate-y-3/4", + bottom: "bottom-0 translate-y-3/4" + } + + ALIGNS = { + start: "left-3", + end: "right-3" + } + + def initialize(side: :bottom, align: :end, **attrs) + @side = side + @align = align + super(**attrs) + end + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "bubble-reactions", side: @side, align: @align}, + class: [ + "absolute z-10 flex w-fit items-center justify-center rounded-full ring-3 ring-card bg-muted shrink-0 gap-1 px-1.5 py-0.5 has-[button]:p-0 text-sm", + SIDES[@side], + ALIGNS[@align] + ] + } + end + end +end diff --git a/gem/lib/ruby_ui/message/message.rb b/gem/lib/ruby_ui/message/message.rb new file mode 100644 index 00000000..6cf005d6 --- /dev/null +++ b/gem/lib/ruby_ui/message/message.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module RubyUI + class Message < Base + def initialize(align: :start, **attrs) + @align = align + super(**attrs) + end + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "message", align: @align}, + class: "group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse" + } + end + end +end diff --git a/gem/lib/ruby_ui/message/message_avatar.rb b/gem/lib/ruby_ui/message/message_avatar.rb new file mode 100644 index 00000000..9df4b6bf --- /dev/null +++ b/gem/lib/ruby_ui/message/message_avatar.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class MessageAvatar < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "message-avatar"}, + class: "flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8" + } + end + end +end diff --git a/gem/lib/ruby_ui/message/message_content.rb b/gem/lib/ruby_ui/message/message_content.rb new file mode 100644 index 00000000..0e24f2c7 --- /dev/null +++ b/gem/lib/ruby_ui/message/message_content.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class MessageContent < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "message-content"}, + class: "flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:[&>[data-slot]]:self-end" + } + end + end +end diff --git a/gem/lib/ruby_ui/message/message_docs.rb b/gem/lib/ruby_ui/message/message_docs.rb new file mode 100644 index 00000000..7488396b --- /dev/null +++ b/gem/lib/ruby_ui/message/message_docs.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +class Views::Docs::Message < Views::Base + def view_template + component = "Message" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new(title: "Message", description: "A chat message layout that pairs an avatar with bubbles, headers, and footers. Built on top of Avatar and Bubble.") + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Conversation", context: self) do + <<~RUBY + div(class: "flex flex-col gap-6") do + Message(align: :end) do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me") + AvatarFallback { "ME" } + end + end + MessageContent do + Bubble do + BubbleContent { "Deploying to prod real quick." } + end + end + end + + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit") + AvatarFallback { "R" } + end + end + MessageContent do + Bubble(variant: :muted) do + BubbleContent { "It's 4:55 PM. On a Friday." } + end + end + end + + Message(align: :end) do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me") + AvatarFallback { "ME" } + end + end + MessageContent do + Bubble do + BubbleContent { "It's a one-line change." } + end + MessageFooter { "Delivered" } + end + end + + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit") + AvatarFallback { "R" } + end + end + MessageContent do + BubbleGroup do + Bubble(variant: :muted) do + BubbleContent { "It's always a one-line change 😭." } + end + Bubble(variant: :muted) do + BubbleContent { "Alright, let me take a look." } + BubbleReactions(role: "img", aria_label: "Reaction: thumbs up") do + span { "👍" } + end + end + end + end + end + end + RUBY + end + + Heading(level: 2) { "With header" } + + render Docs::VisualCodeExample.new(title: "Header and footer", context: self) do + <<~RUBY + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@oliver") + AvatarFallback { "OL" } + end + end + MessageContent do + MessageHeader { "Oliver" } + Bubble(variant: :muted) do + BubbleContent { "Pushed the fix, can you review?" } + end + MessageFooter { "9:41 AM" } + end + end + RUBY + end + + Heading(level: 2) { "Alignment" } + + render Docs::VisualCodeExample.new(title: "Sender and receiver", context: self) do + <<~RUBY + div(class: "flex flex-col gap-6") do + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit") + AvatarFallback { "R" } + end + end + MessageContent do + Bubble(variant: :muted) { BubbleContent { "Aligned to the start." } } + end + end + Message(align: :end) do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me") + AvatarFallback { "ME" } + end + end + MessageContent do + Bubble { BubbleContent { "Aligned to the end." } } + end + end + end + RUBY + end + + Heading(level: 2) { "Group" } + + render Docs::VisualCodeExample.new(title: "Message group", context: self) do + <<~RUBY + MessageGroup do + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit") + AvatarFallback { "R" } + end + end + MessageContent do + Bubble(variant: :muted) { BubbleContent { "First message." } } + end + end + Message do + MessageAvatar do + Avatar(size: :sm) do + AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit") + AvatarFallback { "R" } + end + end + MessageContent do + Bubble(variant: :muted) { BubbleContent { "Second, tighter spacing." } } + 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/message_footer.rb b/gem/lib/ruby_ui/message/message_footer.rb new file mode 100644 index 00000000..ad4042d6 --- /dev/null +++ b/gem/lib/ruby_ui/message/message_footer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class MessageFooter < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "message-footer"}, + class: "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-data-[align=end]/message:justify-end group-has-data-[variant=ghost]/message:px-0" + } + end + end +end diff --git a/gem/lib/ruby_ui/message/message_group.rb b/gem/lib/ruby_ui/message/message_group.rb new file mode 100644 index 00000000..14210d57 --- /dev/null +++ b/gem/lib/ruby_ui/message/message_group.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class MessageGroup < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "message-group"}, + class: "flex min-w-0 flex-col gap-2" + } + end + end +end diff --git a/gem/lib/ruby_ui/message/message_header.rb b/gem/lib/ruby_ui/message/message_header.rb new file mode 100644 index 00000000..f3810c74 --- /dev/null +++ b/gem/lib/ruby_ui/message/message_header.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class MessageHeader < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "message-header"}, + class: "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0" + } + end + end +end diff --git a/gem/test/ruby_ui/bubble_test.rb b/gem/test/ruby_ui/bubble_test.rb new file mode 100644 index 00000000..105a04c2 --- /dev/null +++ b/gem/test/ruby_ui/bubble_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::BubbleTest < ComponentTest + def test_renders_content + output = phlex do + RubyUI.Bubble do + RubyUI.BubbleContent { "Hello" } + end + end + + assert_match(/Hello/, output) + assert_match(/data-slot="bubble"/, output) + assert_match(/data-slot="bubble-content"/, output) + end + + def test_default_variant_and_align + output = phlex { RubyUI.Bubble { RubyUI.BubbleContent { "Hi" } } } + + assert_match(/data-variant="default"/, output) + assert_match(/data-align="start"/, output) + end + + def test_variant_and_align_attributes + output = phlex do + RubyUI.Bubble(variant: :muted, align: :end) { RubyUI.BubbleContent { "Hi" } } + end + + assert_match(/data-variant="muted"/, output) + assert_match(/data-align="end"/, output) + end + + def test_reactions_defaults_to_bottom_end + output = phlex do + RubyUI.Bubble do + RubyUI.BubbleContent { "Hi" } + RubyUI.BubbleReactions { "👍" } + end + end + + assert_match(/data-slot="bubble-reactions"/, output) + assert_match(/data-side="bottom"/, output) + assert_match(/data-align="end"/, output) + end + + def test_reactions_side_and_align + output = phlex do + RubyUI.BubbleReactions(side: :top, align: :start) { "🔥" } + end + + assert_match(/data-side="top"/, output) + assert_match(/data-align="start"/, output) + end + + def test_content_renders_as_anchor + output = phlex do + RubyUI.Bubble { RubyUI.BubbleContent(as: :a, href: "#") { "Open" } } + end + + assert_match(/]*data-slot="bubble-content"[^>]*href="#"/, output) + assert_match(/Open/, output) + end + + def test_content_renders_as_button + output = phlex do + RubyUI.Bubble { RubyUI.BubbleContent(as: :button, type: "button") { "Retry" } } + end + + assert_match(/]*data-slot="bubble-content"/, output) + end + + def test_group_wraps_bubbles + output = phlex do + RubyUI.BubbleGroup do + RubyUI.Bubble { RubyUI.BubbleContent { "a" } } + RubyUI.Bubble { RubyUI.BubbleContent { "b" } } + end + end + + assert_match(/data-slot="bubble-group"/, output) + end +end diff --git a/gem/test/ruby_ui/message_test.rb b/gem/test/ruby_ui/message_test.rb new file mode 100644 index 00000000..9e7a0416 --- /dev/null +++ b/gem/test/ruby_ui/message_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::MessageTest < ComponentTest + def test_renders_message_with_bubble + output = phlex do + RubyUI.Message do + RubyUI.MessageContent do + RubyUI.Bubble { RubyUI.BubbleContent { "Hi" } } + end + end + end + + assert_match(/data-slot="message"/, output) + assert_match(/data-slot="message-content"/, output) + assert_match(/data-slot="bubble"/, output) + assert_match(/Hi/, output) + end + + def test_default_align_start + output = phlex { RubyUI.Message { "x" } } + + assert_match(/data-align="start"/, output) + end + + def test_align_end + output = phlex { RubyUI.Message(align: :end) { "x" } } + + assert_match(/data-align="end"/, output) + end + + def test_avatar_header_footer_slots + output = phlex do + RubyUI.Message do + RubyUI.MessageAvatar { "A" } + RubyUI.MessageContent do + RubyUI.MessageHeader { "Oliver" } + RubyUI.Bubble { RubyUI.BubbleContent { "Hi" } } + RubyUI.MessageFooter { "Delivered" } + end + end + end + + assert_match(/data-slot="message-avatar"/, output) + assert_match(/data-slot="message-header"/, output) + assert_match(/data-slot="message-footer"/, output) + end + + def test_group_wraps_messages + output = phlex do + RubyUI.MessageGroup do + RubyUI.Message { "a" } + RubyUI.Message { "b" } + end + end + + assert_match(/data-slot="message-group"/, output) + end +end diff --git a/mcp/data/registry.json b/mcp/data/registry.json index cd400482..4922db45 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -371,6 +371,82 @@ } ] }, + "bubble": { + "name": "Bubble", + "description": "A chat bubble surface for displaying conversational content, with variants, alignment, grouping, and reactions.", + "files": [ + { + "path": "bubble.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Bubble < Base\n VARIANTS = {\n default: \"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80\",\n secondary: \"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]\",\n muted: \"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]\",\n tinted: \"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]\",\n outline: \"*:data-[slot=bubble-content]:bg-background *:data-[slot=bubble-content]:border-border [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30\",\n ghost: \"*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none\",\n destructive: \"*:data-[slot=bubble-content]:bg-destructive/10 dark:*:data-[slot=bubble-content]:bg-destructive/20 *:data-[slot=bubble-content]:text-destructive [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30\"\n }\n\n def initialize(variant: :default, align: :start, **attrs)\n @variant = variant\n @align = align\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: \"bubble\", variant: @variant, align: @align},\n class: [\n \"group/bubble relative flex w-fit min-w-0 flex-col gap-1 max-w-[80%] data-[align=end]:self-end data-[variant=ghost]:max-w-full\",\n VARIANTS[@variant]\n ]\n }\n end\n end\nend\n" + }, + { + "path": "bubble_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class BubbleContent < Base\n def initialize(as: :div, **attrs)\n @as = as\n super(**attrs)\n end\n\n def view_template(&)\n send(@as, **attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"bubble-content\"},\n class: \"w-fit max-w-full min-w-0 overflow-hidden wrap-break-word rounded-3xl border border-transparent px-3 py-2.5 text-sm leading-relaxed group-data-[align=end]/bubble:self-end [&:is(button,a)]:text-left [&:is(button,a)]:outline-none [&:is(button,a)]:transition-colors [&:is(button,a):focus-visible]:border-ring [&:is(button,a):focus-visible]:ring-3 [&:is(button,a):focus-visible]:ring-ring/30\"\n }\n end\n end\nend\n" + }, + { + "path": "bubble_group.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class BubbleGroup < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"bubble-group\"},\n class: \"flex min-w-0 flex-col gap-2\"\n }\n end\n end\nend\n" + }, + { + "path": "bubble_reactions.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class BubbleReactions < Base\n SIDES = {\n top: \"top-0 -translate-y-3/4\",\n bottom: \"bottom-0 translate-y-3/4\"\n }\n\n ALIGNS = {\n start: \"left-3\",\n end: \"right-3\"\n }\n\n def initialize(side: :bottom, align: :end, **attrs)\n @side = side\n @align = align\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: \"bubble-reactions\", side: @side, align: @align},\n class: [\n \"absolute z-10 flex w-fit items-center justify-center rounded-full ring-3 ring-card bg-muted shrink-0 gap-1 px-1.5 py-0.5 has-[button]:p-0 text-sm\",\n SIDES[@side],\n ALIGNS[@align]\n ]\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Bubble", + "docs_markdown": "# Bubble\n\nA chat bubble surface for displaying conversational content, with variants, alignment, grouping, and reactions.\n\n## Usage\n\n### Default\n\n```ruby\nBubble(align: :end) do\n BubbleContent { \"Hey there! what's up?\" }\nend\n```\n\n### Conversation\n\n```ruby\ndiv(class: \"flex flex-col gap-8\") do\n Bubble(align: :end) do\n BubbleContent { \"Hey there! what's up?\" }\n end\n BubbleGroup do\n Bubble(variant: :muted) do\n BubbleContent { \"Hey! Want to see chat bubbles?\" }\n end\n Bubble(variant: :muted) do\n BubbleContent { \"I can group messages, switch sides, and keep the whole thread easy to scan.\" }\n BubbleReactions(role: \"img\", aria_label: \"Reaction: thumbs up\") do\n span { \"👍\" }\n end\n end\n end\n Bubble(align: :end) do\n BubbleContent { \"Sure. Hit me with your best demo.\" }\n end\nend\n```\n\n## Variants\n\n### Variants\n\n```ruby\ndiv(class: \"flex flex-col gap-4 w-full\") do\n Bubble(variant: :default) { BubbleContent { \"Default\" } }\n Bubble(variant: :secondary) { BubbleContent { \"Secondary\" } }\n Bubble(variant: :muted) { BubbleContent { \"Muted\" } }\n Bubble(variant: :tinted) { BubbleContent { \"Tinted\" } }\n Bubble(variant: :outline) { BubbleContent { \"Outline\" } }\n Bubble(variant: :ghost) { BubbleContent { \"Ghost — unframed, full width for assistant text or markdown.\" } }\n Bubble(variant: :destructive) { BubbleContent { \"Destructive — something went wrong.\" } }\nend\n```\n\n## Alignment\n\n### Start and end\n\n```ruby\ndiv(class: \"flex flex-col gap-4 w-full\") do\n Bubble(align: :start, variant: :muted) do\n BubbleContent { \"Aligned to the start (receiver).\" }\n end\n Bubble(align: :end) do\n BubbleContent { \"Aligned to the end (sender).\" }\n end\nend\n```\n\n## Reactions\n\n### Reactions\n\n```ruby\ndiv(class: \"flex flex-col gap-10 w-full py-6\") do\n Bubble(variant: :muted) do\n BubbleContent { \"Reactions anchor to the bubble edge.\" }\n BubbleReactions(role: \"img\", aria_label: \"Reactions: thumbs up, fire, eyes, and 2 more\") do\n span { \"👍\" }\n span { \"🔥\" }\n span { \"👀\" }\n span { \"+2\" }\n end\n end\n Bubble(align: :end) do\n BubbleContent { \"Place them on top and to the start too.\" }\n BubbleReactions(side: :top, align: :start, role: \"img\", aria_label: \"Reaction: heart\") do\n span { \"❤️\" }\n end\n end\nend\n```\n\n## Group\n\n### Bubble group\n\n```ruby\nBubbleGroup do\n Bubble(variant: :muted) { BubbleContent { \"First message in the group.\" } }\n Bubble(variant: :muted) { BubbleContent { \"Second one, tighter spacing.\" } }\n Bubble(variant: :muted) { BubbleContent { \"Third, all stacked together.\" } }\nend\n```\n\n## Link or button bubble\n\n### Interactive content\n\n```ruby\ndiv(class: \"flex flex-col gap-4 w-full\") do\n Bubble(align: :end) do\n BubbleContent(as: :a, href: \"#\") { \"Tap to open the link →\" }\n end\n Bubble(variant: :outline) do\n BubbleContent(as: :button, type: \"button\") { \"Retry sending\" }\n end\nend\n```\n\n## With Tooltip\n\n### Reveal metadata on hover\n\n```ruby\nTooltip do\n TooltipTrigger(class: \"w-fit\") do\n Bubble(variant: :muted, class: \"max-w-none\") do\n BubbleContent { \"Read 9:41 AM\" }\n end\n end\n TooltipContent do\n Text { \"Delivered and read\" }\n end\nend\n```\n\n## With Popover\n\n### Surface details on demand\n\n```ruby\nPopover do\n PopoverTrigger do\n Bubble(variant: :destructive, class: \"max-w-none\") do\n BubbleContent { \"Message failed to send\" }\n end\n end\n PopoverContent(class: \"w-64\") do\n Text(weight: :semibold) { \"Delivery error\" }\n Text(size: :sm, class: \"text-muted-foreground\") { \"The recipient's inbox is full. Try again later.\" }\n end\nend\n```", + "examples": [ + { + "title": "Default", + "code": "Bubble(align: :end) do\n BubbleContent { \"Hey there! what's up?\" }\nend\n", + "language": "ruby" + }, + { + "title": "Conversation", + "code": "div(class: \"flex flex-col gap-8\") do\n Bubble(align: :end) do\n BubbleContent { \"Hey there! what's up?\" }\n end\n BubbleGroup do\n Bubble(variant: :muted) do\n BubbleContent { \"Hey! Want to see chat bubbles?\" }\n end\n Bubble(variant: :muted) do\n BubbleContent { \"I can group messages, switch sides, and keep the whole thread easy to scan.\" }\n BubbleReactions(role: \"img\", aria_label: \"Reaction: thumbs up\") do\n span { \"👍\" }\n end\n end\n end\n Bubble(align: :end) do\n BubbleContent { \"Sure. Hit me with your best demo.\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Variants", + "code": "div(class: \"flex flex-col gap-4 w-full\") do\n Bubble(variant: :default) { BubbleContent { \"Default\" } }\n Bubble(variant: :secondary) { BubbleContent { \"Secondary\" } }\n Bubble(variant: :muted) { BubbleContent { \"Muted\" } }\n Bubble(variant: :tinted) { BubbleContent { \"Tinted\" } }\n Bubble(variant: :outline) { BubbleContent { \"Outline\" } }\n Bubble(variant: :ghost) { BubbleContent { \"Ghost — unframed, full width for assistant text or markdown.\" } }\n Bubble(variant: :destructive) { BubbleContent { \"Destructive — something went wrong.\" } }\nend\n", + "language": "ruby" + }, + { + "title": "Start and end", + "code": "div(class: \"flex flex-col gap-4 w-full\") do\n Bubble(align: :start, variant: :muted) do\n BubbleContent { \"Aligned to the start (receiver).\" }\n end\n Bubble(align: :end) do\n BubbleContent { \"Aligned to the end (sender).\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Reactions", + "code": "div(class: \"flex flex-col gap-10 w-full py-6\") do\n Bubble(variant: :muted) do\n BubbleContent { \"Reactions anchor to the bubble edge.\" }\n BubbleReactions(role: \"img\", aria_label: \"Reactions: thumbs up, fire, eyes, and 2 more\") do\n span { \"👍\" }\n span { \"🔥\" }\n span { \"👀\" }\n span { \"+2\" }\n end\n end\n Bubble(align: :end) do\n BubbleContent { \"Place them on top and to the start too.\" }\n BubbleReactions(side: :top, align: :start, role: \"img\", aria_label: \"Reaction: heart\") do\n span { \"❤️\" }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Bubble group", + "code": "BubbleGroup do\n Bubble(variant: :muted) { BubbleContent { \"First message in the group.\" } }\n Bubble(variant: :muted) { BubbleContent { \"Second one, tighter spacing.\" } }\n Bubble(variant: :muted) { BubbleContent { \"Third, all stacked together.\" } }\nend\n", + "language": "ruby" + }, + { + "title": "Interactive content", + "code": "div(class: \"flex flex-col gap-4 w-full\") do\n Bubble(align: :end) do\n BubbleContent(as: :a, href: \"#\") { \"Tap to open the link →\" }\n end\n Bubble(variant: :outline) do\n BubbleContent(as: :button, type: \"button\") { \"Retry sending\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Reveal metadata on hover", + "code": "Tooltip do\n TooltipTrigger(class: \"w-fit\") do\n Bubble(variant: :muted, class: \"max-w-none\") do\n BubbleContent { \"Read 9:41 AM\" }\n end\n end\n TooltipContent do\n Text { \"Delivered and read\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Surface details on demand", + "code": "Popover do\n PopoverTrigger do\n Bubble(variant: :destructive, class: \"max-w-none\") do\n BubbleContent { \"Message failed to send\" }\n end\n end\n PopoverContent(class: \"w-64\") do\n Text(weight: :semibold) { \"Delivery error\" }\n Text(size: :sm, class: \"text-muted-foreground\") { \"The recipient's inbox is full. Try again later.\" }\n end\nend\n", + "language": "ruby" + } + ] + }, "button": { "name": "Button", "description": "Displays a button or a component that looks like a button.", @@ -1626,6 +1702,68 @@ } ] }, + "message": { + "name": "Message", + "description": "A chat message layout that pairs an avatar with bubbles, headers, and footers. Built on top of Avatar and Bubble.", + "files": [ + { + "path": "message.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Message < Base\n def initialize(align: :start, **attrs)\n @align = align\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: \"message\", align: @align},\n class: \"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse\"\n }\n end\n end\nend\n" + }, + { + "path": "message_avatar.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageAvatar < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"message-avatar\"},\n class: \"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8\"\n }\n end\n end\nend\n" + }, + { + "path": "message_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"message-content\"},\n class: \"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:[&>[data-slot]]:self-end\"\n }\n end\n end\nend\n" + }, + { + "path": "message_footer.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageFooter < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"message-footer\"},\n class: \"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-data-[align=end]/message:justify-end group-has-data-[variant=ghost]/message:px-0\"\n }\n end\n end\nend\n" + }, + { + "path": "message_group.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageGroup < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"message-group\"},\n class: \"flex min-w-0 flex-col gap-2\"\n }\n end\n end\nend\n" + }, + { + "path": "message_header.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MessageHeader < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"message-header\"},\n class: \"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [ + "Avatar", + "Bubble" + ], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Message", + "docs_markdown": "# Message\n\nA chat message layout that pairs an avatar with bubbles, headers, and footers. Built on top of Avatar and Bubble.\n\n## Usage\n\n### Conversation\n\n```ruby\ndiv(class: \"flex flex-col gap-6\") do\n Message(align: :end) do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/joeldrapper.png\", alt: \"@me\")\n AvatarFallback { \"ME\" }\n end\n end\n MessageContent do\n Bubble do\n BubbleContent { \"Deploying to prod real quick.\" }\n end\n end\n end\n\n Message do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@rabbit\")\n AvatarFallback { \"R\" }\n end\n end\n MessageContent do\n Bubble(variant: :muted) do\n BubbleContent { \"It's 4:55 PM. On a Friday.\" }\n end\n end\n end\n\n Message(align: :end) do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/joeldrapper.png\", alt: \"@me\")\n AvatarFallback { \"ME\" }\n end\n end\n MessageContent do\n Bubble do\n BubbleContent { \"It's a one-line change.\" }\n end\n MessageFooter { \"Delivered\" }\n end\n end\n\n Message do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@rabbit\")\n AvatarFallback { \"R\" }\n end\n end\n MessageContent do\n BubbleGroup do\n Bubble(variant: :muted) do\n BubbleContent { \"It's always a one-line change 😭.\" }\n end\n Bubble(variant: :muted) do\n BubbleContent { \"Alright, let me take a look.\" }\n BubbleReactions(role: \"img\", aria_label: \"Reaction: thumbs up\") do\n span { \"👍\" }\n end\n end\n end\n end\n end\nend\n```\n\n## With header\n\n### Header and footer\n\n```ruby\nMessage do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@oliver\")\n AvatarFallback { \"OL\" }\n end\n end\n MessageContent do\n MessageHeader { \"Oliver\" }\n Bubble(variant: :muted) do\n BubbleContent { \"Pushed the fix, can you review?\" }\n end\n MessageFooter { \"9:41 AM\" }\n end\nend\n```\n\n## Alignment\n\n### Sender and receiver\n\n```ruby\ndiv(class: \"flex flex-col gap-6\") do\n Message do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@rabbit\")\n AvatarFallback { \"R\" }\n end\n end\n MessageContent do\n Bubble(variant: :muted) { BubbleContent { \"Aligned to the start.\" } }\n end\n end\n Message(align: :end) do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/joeldrapper.png\", alt: \"@me\")\n AvatarFallback { \"ME\" }\n end\n end\n MessageContent do\n Bubble { BubbleContent { \"Aligned to the end.\" } }\n end\n end\nend\n```\n\n## Group\n\n### Message group\n\n```ruby\nMessageGroup do\n Message do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@rabbit\")\n AvatarFallback { \"R\" }\n end\n end\n MessageContent do\n Bubble(variant: :muted) { BubbleContent { \"First message.\" } }\n end\n end\n Message do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@rabbit\")\n AvatarFallback { \"R\" }\n end\n end\n MessageContent do\n Bubble(variant: :muted) { BubbleContent { \"Second, tighter spacing.\" } }\n end\n end\nend\n```", + "examples": [ + { + "title": "Conversation", + "code": "div(class: \"flex flex-col gap-6\") do\n Message(align: :end) do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/joeldrapper.png\", alt: \"@me\")\n AvatarFallback { \"ME\" }\n end\n end\n MessageContent do\n Bubble do\n BubbleContent { \"Deploying to prod real quick.\" }\n end\n end\n end\n\n Message do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@rabbit\")\n AvatarFallback { \"R\" }\n end\n end\n MessageContent do\n Bubble(variant: :muted) do\n BubbleContent { \"It's 4:55 PM. On a Friday.\" }\n end\n end\n end\n\n Message(align: :end) do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/joeldrapper.png\", alt: \"@me\")\n AvatarFallback { \"ME\" }\n end\n end\n MessageContent do\n Bubble do\n BubbleContent { \"It's a one-line change.\" }\n end\n MessageFooter { \"Delivered\" }\n end\n end\n\n Message do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@rabbit\")\n AvatarFallback { \"R\" }\n end\n end\n MessageContent do\n BubbleGroup do\n Bubble(variant: :muted) do\n BubbleContent { \"It's always a one-line change 😭.\" }\n end\n Bubble(variant: :muted) do\n BubbleContent { \"Alright, let me take a look.\" }\n BubbleReactions(role: \"img\", aria_label: \"Reaction: thumbs up\") do\n span { \"👍\" }\n end\n end\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Header and footer", + "code": "Message do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@oliver\")\n AvatarFallback { \"OL\" }\n end\n end\n MessageContent do\n MessageHeader { \"Oliver\" }\n Bubble(variant: :muted) do\n BubbleContent { \"Pushed the fix, can you review?\" }\n end\n MessageFooter { \"9:41 AM\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Sender and receiver", + "code": "div(class: \"flex flex-col gap-6\") do\n Message do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@rabbit\")\n AvatarFallback { \"R\" }\n end\n end\n MessageContent do\n Bubble(variant: :muted) { BubbleContent { \"Aligned to the start.\" } }\n end\n end\n Message(align: :end) do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/joeldrapper.png\", alt: \"@me\")\n AvatarFallback { \"ME\" }\n end\n end\n MessageContent do\n Bubble { BubbleContent { \"Aligned to the end.\" } }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Message group", + "code": "MessageGroup do\n Message do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@rabbit\")\n AvatarFallback { \"R\" }\n end\n end\n MessageContent do\n Bubble(variant: :muted) { BubbleContent { \"First message.\" } }\n end\n end\n Message do\n MessageAvatar do\n Avatar(size: :sm) do\n AvatarImage(src: \"https://github.com/shadcn.png\", alt: \"@rabbit\")\n AvatarFallback { \"R\" }\n end\n end\n MessageContent do\n Bubble(variant: :muted) { BubbleContent { \"Second, tighter spacing.\" } }\n end\n end\nend\n", + "language": "ruby" + } + ] + }, "native_select": { "name": "NativeSelect", "description": "A styled native HTML select element with consistent design system integration.",