Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions audit/component_map/tooltip.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
shadcn: tooltip
shadcn_sha: 8a3536678a528cc012b9e62484290e79d0be410f
kind: popover
docs_view: tooltip
# The fixture follows shadcn's "Basic" example: an outline Button as the
# trigger and a <p> in the content. Both sides open at once (delay 0) and stay
# open on click (closeOnClick false / close_on_click: false) so collect's
# click, which hovers the trigger first, reaches the open state.
parts:
Tooltip: tooltip
TooltipTrigger: tooltip-trigger
TooltipContent: tooltip-content
# TooltipTrigger is an inline-flex <div> wrapper in RubyUI; the Button inside
# it is what shadcn renders as the trigger (render={<Button />}), so that is
# compared. Base UI's Tooltip.Root renders no element.
selectors:
Tooltip: null
TooltipTrigger: '[data-slot="tooltip-trigger"] > button'
TooltipContent: '[data-slot="tooltip-content"]'
# side/sideOffset/align/alignOffset from the .tsx (TooltipContent);
# delay/closeDelay/closeOnClick from the Base UI Tooltip.Trigger;
# open/defaultOpen/onOpenChange/disabled from Tooltip.Root (RubyUI's open: is
# the initial state; the open value attribute and the change event cover the
# controlled use).
props:
Tooltip: {open: null, defaultOpen: open, onOpenChange: null, disabled: disabled}
TooltipTrigger: {delay: delay, closeDelay: close_delay, closeOnClick: close_on_click}
TooltipContent: {side: side, sideOffset: side_offset, align: align, alignOffset: align_offset}
# placement: is the older API (TooltipContent side: wins over it).
rubyui_only:
Tooltip: [placement]
fixture_shadcn: [button]
3 changes: 3 additions & 0 deletions audit/fixtures/tooltip/content.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Same strings on both sides. Follows shadcn's tooltip "Basic" example.
trigger: Show Tooltip
body: Add to library
20 changes: 20 additions & 0 deletions audit/fixtures/tooltip/rubyui.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module ShadcnAuditFixtures
# RubyUI side of audit/fixtures/tooltip (strings in content.yml).
# close_on_click: false matches the shadcn side, so collect's click leaves it open.
class Tooltip < Phlex::HTML
include RubyUI

def view_template
Tooltip do
TooltipTrigger(close_on_click: false) do
Button(variant: :outline, class: "w-fit", data: {audit_trigger: true}) { "Show Tooltip" }
end
TooltipContent do
p { "Add to library" }
end
end
end
end
end
28 changes: 28 additions & 0 deletions audit/fixtures/tooltip/shadcn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"

// shadcn's "Basic" example inside its TooltipProvider (delay 0). closeOnClick
// is off so collect's click (a hover, then a press) leaves it open.
export default function TooltipFixture() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
closeOnClick={false}
data-audit-trigger=""
render={<Button variant="outline" className="w-fit" />}
>
Show Tooltip
</TooltipTrigger>
<TooltipContent>
<p>Add to library</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
96 changes: 90 additions & 6 deletions docs/app/views/docs/tooltip.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,113 @@ def view_template

Heading(level: 2) { "Usage" }

render Docs::VisualCodeExample.new(title: "Example", context: self) do
render Docs::VisualCodeExample.new(title: "Example", description: "The tooltip opens while the pointer rests on the trigger or when the trigger gets keyboard focus. The pointer can move onto the tooltip; it closes when the pointer leaves both, on blur (unless the pointer is still over it), on Escape, or when the trigger is pressed. The trigger's button is described by the tooltip (aria-describedby).", context: self) do
<<~RUBY
Tooltip do
TooltipTrigger do
Button(variant: :outline, icon: true) do
Button(variant: :outline) { "Show Tooltip" }
end
TooltipContent do
p { "Add to library" }
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Sides", description: "side: places the tooltip around the trigger: :top (the default), :right, :bottom, :left, or the logical :inline_start and :inline_end, which follow the writing direction. side_offset: is the gap in pixels (4 by default); align: (:start, :center, :end) and align_offset: line it up along that edge. It flips to the other side when there is no room.", context: self) do
<<~RUBY
div(class: "flex flex-wrap gap-2") do
[:inline_start, :left, :top, :bottom, :right, :inline_end].each do |side|
Tooltip do
TooltipTrigger do
Button(variant: :outline, class: "capitalize") { side.to_s.tr("_", " ") }
end
TooltipContent(side: side) do
p { "Add to library" }
end
end
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "With icon", description: "An icon-only trigger still needs an accessible name: the tooltip describes the button, it doesn't name it.", context: self) do
<<~RUBY
Tooltip do
TooltipTrigger do
Button(variant: :ghost, icon: true) do
bookmark_icon
span(class: "sr-only") { "Bookmark" }
end
end
TooltipContent do
Text { "Add to library" }
p { "Add to library" }
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Long content", description: "Long text wraps at 20rem.", context: self) do
<<~RUBY
Tooltip do
TooltipTrigger do
Button(variant: :outline) { "Show Tooltip" }
end
TooltipContent do
plain "To learn more about how this works, check out the docs. If you have any questions, please reach out to us."
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Long content", context: self) do
render Docs::VisualCodeExample.new(title: "Disabled button", description: "A disabled button gets no pointer events, so the wrapper still opens the tooltip on hover.", context: self) do
<<~RUBY
Tooltip do
TooltipTrigger do
Button(variant: :outline) { "Hover me" }
Button(variant: :outline, disabled: true) { "Disabled" }
end
TooltipContent do
p { "This feature is currently unavailable" }
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "With keyboard shortcut", context: self) do
<<~RUBY
Tooltip do
TooltipTrigger do
Button(variant: :outline, size: :sm) { "Save" }
end
TooltipContent do
plain "Save Changes"
ShortcutKey { "S" }
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Delay and click", description: "delay: waits that many milliseconds of pointer rest before opening (0 by default), close_delay: waits before closing (0 by default, plus a 100 ms grace to cross onto the tooltip). close_on_click: false keeps it open when the trigger is pressed.", context: self) do
<<~RUBY
Tooltip do
TooltipTrigger(delay: 500, close_on_click: false) do
Button(variant: :outline) { "Hover and wait" }
end
TooltipContent do
p { "Opened after 500 ms" }
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Disabled tooltip", description: "disabled: true keeps the tooltip from opening. open: true renders it open on load. Changing the data-ruby-ui--tooltip-open-value or -disabled-value attribute later opens, closes or disables it, and each open or close dispatches ruby-ui--tooltip:change with detail.open.", context: self) do
<<~RUBY
Tooltip(disabled: true) do
TooltipTrigger do
Button(variant: :outline) { "No tooltip" }
end
TooltipContent do
Text { "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." }
p { "You won't see this" }
end
end
RUBY
Expand Down
21 changes: 8 additions & 13 deletions gem/lib/ruby_ui/sidebar/sidebar_menu_button.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ class SidebarMenuButton < Base
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0"
}.freeze

# The tooltip is cloned into <body>, outside the sidebar, so it hides itself
# with a body-level selector: only shown while a desktop sidebar is
# collapsed (shadcn: hidden={state !== "collapsed" || isMobile}).
TOOLTIP_CLASSES = "[body:has([data-slot=sidebar][data-state=expanded])_&]:hidden max-md:hidden"
# The tooltip controller places the content right after this button, inside
# its own sidebar, so it hides from that sidebar's state alone: shown only
# while the desktop sidebar around it is collapsed (shadcn:
# hidden={state !== "collapsed" || isMobile}). A second, expanded sidebar
# on the page no longer hides it.
TOOLTIP_CLASSES = "[[data-slot=sidebar]:not([data-state=collapsed])_&]:hidden max-md:hidden"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved

# tooltip: label shown on the right when the sidebar is collapsed to icons.
def initialize(as: :button, variant: :default, size: :default, active: false, tooltip: nil, **attrs)
Expand All @@ -39,7 +41,7 @@ def view_template(&block)

tag(@as, **mix(attrs, tooltip_attrs)) do
# First child, so [&>span:last-child]:truncate still finds the label.
TooltipContent(class: TOOLTIP_CLASSES) { @tooltip }
TooltipContent(side: :right, class: TOOLTIP_CLASSES) { @tooltip }
yield if block
end
end
Expand All @@ -50,14 +52,7 @@ def tooltip_attrs
{
data: {
controller: "ruby-ui--tooltip",
ruby_ui__tooltip_placement_value: "right",
ruby_ui__tooltip_target: "trigger",
action: [
"mouseenter->ruby-ui--tooltip#show",
"mouseleave->ruby-ui--tooltip#hide",
"focus->ruby-ui--tooltip#show",
"blur->ruby-ui--tooltip#hide"
]
ruby_ui__tooltip_target: "trigger"
}
}
end
Expand Down
17 changes: 15 additions & 2 deletions gem/lib/ruby_ui/tooltip/tooltip.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
# frozen_string_literal: true

module RubyUI
# Root of the tooltip: scopes the trigger and the content for the Stimulus
# controller. open: renders it open on load (Base UI's defaultOpen) and
# disabled: keeps it from opening (Base UI's Root disabled). Setting the
# data-ruby-ui--tooltip-open-value or -disabled-value attribute later
# opens, closes or disables it; each open or close dispatches
# ruby-ui--tooltip:change with detail {open:}.
#
# placement: is the older API: TooltipContent's side: wins over it.
class Tooltip < Base
def initialize(placement: "top", **attrs)
def initialize(placement: nil, open: false, disabled: false, **attrs)
@placement = placement
@open = open
@disabled = disabled
super(**attrs)
end

Expand All @@ -16,8 +26,11 @@ def view_template(&)
def default_attrs
{
data: {
slot: "tooltip",
controller: "ruby-ui--tooltip",
ruby_ui__tooltip_placement_value: @placement
ruby_ui__tooltip_placement_value: @placement&.to_s,
ruby_ui__tooltip_open_value: (!!@open).to_s,
ruby_ui__tooltip_disabled_value: (!!@disabled).to_s
},
class: "group"
}
Expand Down
75 changes: 63 additions & 12 deletions gem/lib/ruby_ui/tooltip/tooltip_content.rb
Original file line number Diff line number Diff line change
@@ -1,31 +1,82 @@
# frozen_string_literal: true

module RubyUI
# The popup, kept in a <template> until the controller connects. The
# controller places the copy right after the trigger, so it stays inside
# the same container (a Sidebar hides its own tooltips from its state), and
# opens it as a native popover="manual" in the top layer, where no ancestor
# with overflow: hidden clips it. role="tooltip"; the trigger's focusable
# element points at it with aria-describedby.
#
# side:/align: pick the placement (side also takes the logical
# :inline_start / :inline_end), side_offset:/align_offset: the gap, with the
# defaults of shadcn's TooltipContent. Without side:, Tooltip's placement:
# applies, then top. It flips to the other side when there is no room.
class TooltipContent < Base
def initialize(**attrs)
@id = "tooltip#{SecureRandom.hex(4)}"
super
SIDES = %i[top right bottom left inline_start inline_end].freeze
ALIGNS = %i[start center end].freeze

def initialize(side: nil, side_offset: 4, align: nil, align_offset: 0, **attrs)
@id = "tooltip-#{SecureRandom.hex(4)}"
@side = SIDES.include?(side&.to_sym) ? side.to_s.tr("_", "-") : nil
@align = ALIGNS.include?(align&.to_sym) ? align.to_s : nil
@side_offset = side_offset
@align_offset = align_offset
super(**attrs)
end

def view_template(&)
def view_template(&block)
template(data: {ruby_ui__tooltip_target: "content"}) do
div(**attrs, &)
div(**attrs) do
yield if block
span(**arrow_attrs)
end
end
end

private

def arrow_attrs
{
aria: {hidden: "true"},
data: {slot: "tooltip-arrow"},
class: [
"absolute z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground",
"data-[side=bottom]:top-1 data-[side=top]:-bottom-2.5",
"data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2",
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
# The controller writes the physical side, so logical sides point right in RTL too.
"data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2"
]
}
end

def default_attrs
{
id: @id,
popover: "manual",
role: "tooltip",
data: {
slot: "tooltip-content",
# Kept in sync with data-open/data-closed for CSS that targets data-[state=...].
state: "closed",
closed: "",
preferred_side: @side,
preferred_align: @align,
side_offset: @side_offset,
align_offset: @align_offset
},
class: [
"invisible pointer-events-none w-fit max-w-[calc(100vw-2rem)] text-balance break-words absolute top-0 left-0 z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md",
"data-[placement=bottom]:slide-in-from-top-2",
"data-[placement=left]:slide-in-from-right-2",
"data-[placement=right]:slide-in-from-left-2",
"data-[placement=top]:slide-in-from-bottom-2",
"data-[state=open]:visible data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:visible data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards"
# Resets the UA popover box (overflow too, or it clips the arrow);
# inline-flex must not beat the UA's hidden closed popover.
"fixed inset-auto m-0 border-0 overflow-visible [&:not(:popover-open)]:hidden",
"z-50 w-fit max-w-xs origin-(--transform-origin) bg-foreground text-background",
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs",
"has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:fill-mode-forwards",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
]
}
end
Expand Down
Loading
Loading