Skip to content

Commit d95814f

Browse files
authored
[render preview] [Feature] Add Bubble component (#445)
* [Feature] Add Bubble component Port the shadcn Bubble component: a chat bubble surface with 7 variants (default, secondary, muted, tinted, outline, ghost, destructive), start/end alignment, grouping, and edge-anchored reactions. - Translates shadcn's cn-bubble-* CSS layer to real Tailwind v4 utilities and RubyUI theme tokens (no custom CSS shipped). - BubbleContent is polymorphic via as: (:div/:a/:button) for link/button bubbles — the idiomatic Phlex equivalent of shadcn's asChild. - No JS. Composes with Tooltip/Popover (docs examples included). Docs page, route, controller, menu entry and site_files updated. * [Bug Fix] Bubble: simplify BubbleContent interactive styles Collapse the duplicated [&_button]/[&_a] pairs into [&:is(button,a)] selectors targeting the content element itself (the polymorphic as: :button/:a case), matching the variant hover selectors. Halves the class string with identical behavior. * [Feature] Bubble: rebuild MCP registry
1 parent 0f6de87 commit d95814f

15 files changed

Lines changed: 627 additions & 0 deletions

File tree

docs/app/components/shared/components_list.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def components
1212
{name: "Avatar", path: docs_avatar_path},
1313
{name: "Badge", path: docs_badge_path},
1414
{name: "Breadcrumb", path: docs_breadcrumb_path},
15+
{name: "Bubble", path: docs_bubble_path},
1516
{name: "Button", path: docs_button_path},
1617
{name: "Calendar", path: docs_calendar_path},
1718
{name: "Card", path: docs_card_path},

docs/app/controllers/docs_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ def breadcrumb
7474
render Views::Docs::Breadcrumb.new
7575
end
7676

77+
def bubble
78+
render Views::Docs::Bubble.new
79+
end
80+
7781
def button
7882
render Views::Docs::Button.new
7983
end

docs/app/lib/site_files.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class SiteFiles
8686
{title: "Avatar", path: "/docs/avatar", description: "Image and fallback primitives for representing a user."},
8787
{title: "Badge", path: "/docs/badge", description: "Small status or label element."},
8888
{title: "Breadcrumb", path: "/docs/breadcrumb", description: "Navigation trail showing the current location in a hierarchy."},
89+
{title: "Bubble", path: "/docs/bubble", description: "Chat bubble surface with variants, alignment, grouping, and reactions."},
8990
{title: "Button", path: "/docs/button", description: "Button component and button-like variants."},
9091
{title: "Calendar", path: "/docs/calendar", description: "Date field component for entering and editing dates."},
9192
{title: "Card", path: "/docs/card", description: "Content container with header, content, and footer primitives."},

docs/app/views/docs/bubble.rb

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# frozen_string_literal: true
2+
3+
class Views::Docs::Bubble < Views::Base
4+
def view_template
5+
component = "Bubble"
6+
7+
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
8+
render Docs::Header.new(title: "Bubble", description: "A chat bubble surface for displaying conversational content, with variants, alignment, grouping, and reactions.")
9+
10+
Heading(level: 2) { "Usage" }
11+
12+
render Docs::VisualCodeExample.new(title: "Default", context: self) do
13+
<<~RUBY
14+
Bubble(align: :end) do
15+
BubbleContent { "Hey there! what's up?" }
16+
end
17+
RUBY
18+
end
19+
20+
render Docs::VisualCodeExample.new(title: "Conversation", context: self) do
21+
<<~RUBY
22+
div(class: "flex flex-col gap-8") do
23+
Bubble(align: :end) do
24+
BubbleContent { "Hey there! what's up?" }
25+
end
26+
BubbleGroup do
27+
Bubble(variant: :muted) do
28+
BubbleContent { "Hey! Want to see chat bubbles?" }
29+
end
30+
Bubble(variant: :muted) do
31+
BubbleContent { "I can group messages, switch sides, and keep the whole thread easy to scan." }
32+
BubbleReactions(role: "img", aria_label: "Reaction: thumbs up") do
33+
span { "👍" }
34+
end
35+
end
36+
end
37+
Bubble(align: :end) do
38+
BubbleContent { "Sure. Hit me with your best demo." }
39+
end
40+
end
41+
RUBY
42+
end
43+
44+
Heading(level: 2) { "Variants" }
45+
46+
render Docs::VisualCodeExample.new(title: "Variants", context: self) do
47+
<<~RUBY
48+
div(class: "flex flex-col gap-4 w-full") do
49+
Bubble(variant: :default) { BubbleContent { "Default" } }
50+
Bubble(variant: :secondary) { BubbleContent { "Secondary" } }
51+
Bubble(variant: :muted) { BubbleContent { "Muted" } }
52+
Bubble(variant: :tinted) { BubbleContent { "Tinted" } }
53+
Bubble(variant: :outline) { BubbleContent { "Outline" } }
54+
Bubble(variant: :ghost) { BubbleContent { "Ghost — unframed, full width for assistant text or markdown." } }
55+
Bubble(variant: :destructive) { BubbleContent { "Destructive — something went wrong." } }
56+
end
57+
RUBY
58+
end
59+
60+
Heading(level: 2) { "Alignment" }
61+
62+
render Docs::VisualCodeExample.new(title: "Start and end", context: self) do
63+
<<~RUBY
64+
div(class: "flex flex-col gap-4 w-full") do
65+
Bubble(align: :start, variant: :muted) do
66+
BubbleContent { "Aligned to the start (receiver)." }
67+
end
68+
Bubble(align: :end) do
69+
BubbleContent { "Aligned to the end (sender)." }
70+
end
71+
end
72+
RUBY
73+
end
74+
75+
Heading(level: 2) { "Reactions" }
76+
77+
render Docs::VisualCodeExample.new(title: "Reactions", context: self) do
78+
<<~RUBY
79+
div(class: "flex flex-col gap-10 w-full py-6") do
80+
Bubble(variant: :muted) do
81+
BubbleContent { "Reactions anchor to the bubble edge." }
82+
BubbleReactions(role: "img", aria_label: "Reactions: thumbs up, fire, eyes, and 2 more") do
83+
span { "👍" }
84+
span { "🔥" }
85+
span { "👀" }
86+
span { "+2" }
87+
end
88+
end
89+
Bubble(align: :end) do
90+
BubbleContent { "Place them on top and to the start too." }
91+
BubbleReactions(side: :top, align: :start, role: "img", aria_label: "Reaction: heart") do
92+
span { "❤️" }
93+
end
94+
end
95+
end
96+
RUBY
97+
end
98+
99+
Heading(level: 2) { "Group" }
100+
101+
render Docs::VisualCodeExample.new(title: "Bubble group", context: self) do
102+
<<~RUBY
103+
BubbleGroup do
104+
Bubble(variant: :muted) { BubbleContent { "First message in the group." } }
105+
Bubble(variant: :muted) { BubbleContent { "Second one, tighter spacing." } }
106+
Bubble(variant: :muted) { BubbleContent { "Third, all stacked together." } }
107+
end
108+
RUBY
109+
end
110+
111+
Heading(level: 2) { "Link or button bubble" }
112+
113+
render Docs::VisualCodeExample.new(title: "Interactive content", context: self) do
114+
<<~RUBY
115+
div(class: "flex flex-col gap-4 w-full") do
116+
Bubble(align: :end) do
117+
BubbleContent(as: :a, href: "#") { "Tap to open the link →" }
118+
end
119+
Bubble(variant: :outline) do
120+
BubbleContent(as: :button, type: "button") { "Retry sending" }
121+
end
122+
end
123+
RUBY
124+
end
125+
126+
Heading(level: 2) { "With Tooltip" }
127+
128+
render Docs::VisualCodeExample.new(title: "Reveal metadata on hover", context: self) do
129+
<<~RUBY
130+
Tooltip do
131+
TooltipTrigger(class: "w-fit") do
132+
Bubble(variant: :muted, class: "max-w-none") do
133+
BubbleContent { "Read 9:41 AM" }
134+
end
135+
end
136+
TooltipContent do
137+
Text { "Delivered and read" }
138+
end
139+
end
140+
RUBY
141+
end
142+
143+
Heading(level: 2) { "With Popover" }
144+
145+
render Docs::VisualCodeExample.new(title: "Surface details on demand", context: self) do
146+
<<~RUBY
147+
Popover do
148+
PopoverTrigger do
149+
Bubble(variant: :destructive, class: "max-w-none") do
150+
BubbleContent { "Message failed to send" }
151+
end
152+
end
153+
PopoverContent(class: "w-64") do
154+
Text(weight: :semibold) { "Delivery error" }
155+
Text(size: :sm, class: "text-muted-foreground") { "The recipient's inbox is full. Try again later." }
156+
end
157+
end
158+
RUBY
159+
end
160+
161+
render Components::ComponentSetup::Tabs.new(component_name: component)
162+
163+
# components
164+
render Docs::ComponentsTable.new(component_files(component))
165+
end
166+
end
167+
end

docs/config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
get "avatar", to: "docs#avatar", as: :docs_avatar
3131
get "badge", to: "docs#badge", as: :docs_badge
3232
get "breadcrumb", to: "docs#breadcrumb", as: :docs_breadcrumb
33+
get "bubble", to: "docs#bubble", as: :docs_bubble
3334
get "button", to: "docs#button", as: :docs_button
3435
get "card", to: "docs#card", as: :docs_card
3536
get "carousel", to: "docs#carousel", as: :docs_carousel

docs/public/llms-full.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ This file expands the curated /llms.txt map into a compact reference that can be
118118
- URL: https://rubyui.com/docs/breadcrumb
119119
- Summary: Navigation trail showing the current location in a hierarchy.
120120

121+
### Bubble
122+
123+
- URL: https://rubyui.com/docs/bubble
124+
- Summary: Chat bubble surface with variants, alignment, grouping, and reactions.
125+
121126
### Button
122127

123128
- URL: https://rubyui.com/docs/button

docs/public/llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Use the core docs first for installation, theming, dark mode, and customization
2828
- [Avatar](https://rubyui.com/docs/avatar): Image and fallback primitives for representing a user.
2929
- [Badge](https://rubyui.com/docs/badge): Small status or label element.
3030
- [Breadcrumb](https://rubyui.com/docs/breadcrumb): Navigation trail showing the current location in a hierarchy.
31+
- [Bubble](https://rubyui.com/docs/bubble): Chat bubble surface with variants, alignment, grouping, and reactions.
3132
- [Button](https://rubyui.com/docs/button): Button component and button-like variants.
3233
- [Calendar](https://rubyui.com/docs/calendar): Date field component for entering and editing dates.
3334
- [Card](https://rubyui.com/docs/card): Content container with header, content, and footer primitives.

docs/public/sitemap.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@
9595
<changefreq>monthly</changefreq>
9696
<priority>0.7</priority>
9797
</url>
98+
<url>
99+
<loc>https://rubyui.com/docs/bubble</loc>
100+
<changefreq>monthly</changefreq>
101+
<priority>0.7</priority>
102+
</url>
98103
<url>
99104
<loc>https://rubyui.com/docs/button</loc>
100105
<changefreq>monthly</changefreq>

gem/lib/ruby_ui/bubble/bubble.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class Bubble < Base
5+
VARIANTS = {
6+
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",
7+
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%)]",
8+
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%)]",
9+
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)]",
10+
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",
11+
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",
12+
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"
13+
}
14+
15+
def initialize(variant: :default, align: :start, **attrs)
16+
@variant = variant
17+
@align = align
18+
super(**attrs)
19+
end
20+
21+
def view_template(&)
22+
div(**attrs, &)
23+
end
24+
25+
private
26+
27+
def default_attrs
28+
{
29+
data: {slot: "bubble", variant: @variant, align: @align},
30+
class: [
31+
"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",
32+
VARIANTS[@variant]
33+
]
34+
}
35+
end
36+
end
37+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class BubbleContent < Base
5+
def initialize(as: :div, **attrs)
6+
@as = as
7+
super(**attrs)
8+
end
9+
10+
def view_template(&)
11+
send(@as, **attrs, &)
12+
end
13+
14+
private
15+
16+
def default_attrs
17+
{
18+
data: {slot: "bubble-content"},
19+
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"
20+
}
21+
end
22+
end
23+
end

0 commit comments

Comments
 (0)