From d932ac84a7101829d447b709b68369dc9d8683fb Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 18 Jul 2025 14:41:19 -0400 Subject: [PATCH] Initial tooltip component --- app/components/DS/tooltip.html.erb | 9 ++ app/components/DS/tooltip.rb | 17 ++++ .../UI/account/entries_date_group.html.erb | 4 + .../controllers/ds_tooltip_controller.js | 85 +++++++++++++++++++ .../previews/tooltip_component_preview.rb | 32 +++++++ 5 files changed, 147 insertions(+) create mode 100644 app/components/DS/tooltip.html.erb create mode 100644 app/components/DS/tooltip.rb create mode 100644 app/javascript/controllers/ds_tooltip_controller.js create mode 100644 test/components/previews/tooltip_component_preview.rb diff --git a/app/components/DS/tooltip.html.erb b/app/components/DS/tooltip.html.erb new file mode 100644 index 00000000..a5bc1b75 --- /dev/null +++ b/app/components/DS/tooltip.html.erb @@ -0,0 +1,9 @@ +
+ <%= helpers.icon icon_name, size: size, color: color %> + + +
\ No newline at end of file diff --git a/app/components/DS/tooltip.rb b/app/components/DS/tooltip.rb new file mode 100644 index 00000000..9d5f62a7 --- /dev/null +++ b/app/components/DS/tooltip.rb @@ -0,0 +1,17 @@ +class DS::Tooltip < ApplicationComponent + attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color + + def initialize(text: nil, placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default") + @text = text + @placement = placement + @offset = offset + @cross_axis = cross_axis + @icon_name = icon + @size = size + @color = color + end + + def tooltip_content + content? ? content : @text + end +end \ No newline at end of file diff --git a/app/components/UI/account/entries_date_group.html.erb b/app/components/UI/account/entries_date_group.html.erb index ddff1114..afad3436 100644 --- a/app/components/UI/account/entries_date_group.html.erb +++ b/app/components/UI/account/entries_date_group.html.erb @@ -16,6 +16,10 @@
+
+ <%= balance_trend.current.format %> + <%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments") %> +
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
diff --git a/app/javascript/controllers/ds_tooltip_controller.js b/app/javascript/controllers/ds_tooltip_controller.js new file mode 100644 index 00000000..8f8760d8 --- /dev/null +++ b/app/javascript/controllers/ds_tooltip_controller.js @@ -0,0 +1,85 @@ +import { + autoUpdate, + computePosition, + flip, + offset, + shift, +} from "@floating-ui/dom"; +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["tooltip"]; + static values = { + placement: { type: String, default: "top" }, + offset: { type: Number, default: 10 }, + crossAxis: { type: Number, default: 0 }, + }; + + connect() { + this._cleanup = null; + this.boundUpdate = this.update.bind(this); + this.addEventListeners(); + } + + disconnect() { + this.removeEventListeners(); + this.stopAutoUpdate(); + } + + addEventListeners() { + this.element.addEventListener("mouseenter", this.show); + this.element.addEventListener("mouseleave", this.hide); + } + + removeEventListeners() { + this.element.removeEventListener("mouseenter", this.show); + this.element.removeEventListener("mouseleave", this.hide); + } + + show = () => { + this.tooltipTarget.style.display = "block"; + this.startAutoUpdate(); + this.update(); + }; + + hide = () => { + this.tooltipTarget.style.display = "none"; + this.stopAutoUpdate(); + }; + + startAutoUpdate() { + if (!this._cleanup) { + this._cleanup = autoUpdate( + this.element.firstElementChild, // Use the icon as the reference element + this.tooltipTarget, + this.boundUpdate + ); + } + } + + stopAutoUpdate() { + if (this._cleanup) { + this._cleanup(); + this._cleanup = null; + } + } + + update() { + computePosition(this.element.firstElementChild, this.tooltipTarget, { + placement: this.placementValue, + middleware: [ + offset({ + mainAxis: this.offsetValue, + crossAxis: this.crossAxisValue, + }), + flip(), + shift({ padding: 5 }), + ], + }).then(({ x, y }) => { + Object.assign(this.tooltipTarget.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); + } +} \ No newline at end of file diff --git a/test/components/previews/tooltip_component_preview.rb b/test/components/previews/tooltip_component_preview.rb new file mode 100644 index 00000000..c2bad062 --- /dev/null +++ b/test/components/previews/tooltip_component_preview.rb @@ -0,0 +1,32 @@ +class TooltipComponentPreview < ViewComponent::Preview + # @param text text + # @param placement select [top, right, bottom, left] + # @param offset number + # @param cross_axis number + # @param icon text + # @param size select [xs, sm, md, lg, xl, 2xl] + # @param color select [default, white, success, warning, destructive, current] + def default(text: "This is helpful information", placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default") + render DS::Tooltip.new( + text: text, + placement: placement, + offset: offset, + cross_axis: cross_axis, + icon: icon, + size: size, + color: color + ) + end + + def with_block_content + render DS::Tooltip.new(icon: "help-circle", color: "warning") do + tag.div do + tag.p("Custom content with formatting:", class: "font-medium mb-1") + + tag.ul(class: "list-disc list-inside text-xs") do + tag.li("First item") + + tag.li("Second item") + end + end + end + end +end \ No newline at end of file