From da5570559c7b43457ef7b58fee033efd1c01a7b6 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Thu, 7 May 2026 10:28:39 -0300 Subject: [PATCH 1/2] [Bug Fix] Add Avatar image fallback handling (#259) --- docs/app/javascript/controllers/index.js | 3 ++ .../controllers/ruby_ui/avatar_controller.js | 29 +++++++++++++++++++ gem/lib/ruby_ui/avatar/avatar.rb | 3 ++ gem/lib/ruby_ui/avatar/avatar_controller.js | 29 +++++++++++++++++++ gem/lib/ruby_ui/avatar/avatar_fallback.rb | 3 ++ gem/lib/ruby_ui/avatar/avatar_image.rb | 6 +++- gem/test/ruby_ui/avatar_test.rb | 5 ++++ 7 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 docs/app/javascript/controllers/ruby_ui/avatar_controller.js create mode 100644 gem/lib/ruby_ui/avatar/avatar_controller.js diff --git a/docs/app/javascript/controllers/index.js b/docs/app/javascript/controllers/index.js index e68815bd9..60911b677 100644 --- a/docs/app/javascript/controllers/index.js +++ b/docs/app/javascript/controllers/index.js @@ -13,6 +13,9 @@ application.register("ruby-ui--accordion", RubyUi__AccordionController) import RubyUi__AlertDialogController from "./ruby_ui/alert_dialog_controller" application.register("ruby-ui--alert-dialog", RubyUi__AlertDialogController) +import RubyUi__AvatarController from "./ruby_ui/avatar_controller" +application.register("ruby-ui--avatar", RubyUi__AvatarController) + import RubyUi__CalendarController from "./ruby_ui/calendar_controller" application.register("ruby-ui--calendar", RubyUi__CalendarController) diff --git a/docs/app/javascript/controllers/ruby_ui/avatar_controller.js b/docs/app/javascript/controllers/ruby_ui/avatar_controller.js new file mode 100644 index 000000000..3643f26a7 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/avatar_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["image", "fallback"]; + + connect() { + if (!this.hasImageTarget) return; + + if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) { + this.showImage(); + } else { + this.showFallback(); + } + } + + showImage() { + this.imageTargets.forEach((image) => image.classList.remove("hidden")); + this.fallbackTargets.forEach((fallback) => + fallback.classList.add("hidden"), + ); + } + + showFallback() { + this.imageTargets.forEach((image) => image.classList.add("hidden")); + this.fallbackTargets.forEach((fallback) => + fallback.classList.remove("hidden"), + ); + } +} diff --git a/gem/lib/ruby_ui/avatar/avatar.rb b/gem/lib/ruby_ui/avatar/avatar.rb index 01b967818..314ae582b 100644 --- a/gem/lib/ruby_ui/avatar/avatar.rb +++ b/gem/lib/ruby_ui/avatar/avatar.rb @@ -24,6 +24,9 @@ def view_template(&) def default_attrs { + data: { + controller: "ruby-ui--avatar" + }, class: ["relative flex shrink-0 overflow-hidden rounded-full", @size_classes] } end diff --git a/gem/lib/ruby_ui/avatar/avatar_controller.js b/gem/lib/ruby_ui/avatar/avatar_controller.js new file mode 100644 index 000000000..3643f26a7 --- /dev/null +++ b/gem/lib/ruby_ui/avatar/avatar_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["image", "fallback"]; + + connect() { + if (!this.hasImageTarget) return; + + if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) { + this.showImage(); + } else { + this.showFallback(); + } + } + + showImage() { + this.imageTargets.forEach((image) => image.classList.remove("hidden")); + this.fallbackTargets.forEach((fallback) => + fallback.classList.add("hidden"), + ); + } + + showFallback() { + this.imageTargets.forEach((image) => image.classList.add("hidden")); + this.fallbackTargets.forEach((fallback) => + fallback.classList.remove("hidden"), + ); + } +} diff --git a/gem/lib/ruby_ui/avatar/avatar_fallback.rb b/gem/lib/ruby_ui/avatar/avatar_fallback.rb index a1a5a5385..29c1b6b6e 100644 --- a/gem/lib/ruby_ui/avatar/avatar_fallback.rb +++ b/gem/lib/ruby_ui/avatar/avatar_fallback.rb @@ -10,6 +10,9 @@ def view_template(&) def default_attrs { + data: { + ruby_ui__avatar_target: "fallback" + }, class: "flex h-full w-full items-center justify-center rounded-full bg-muted" } end diff --git a/gem/lib/ruby_ui/avatar/avatar_image.rb b/gem/lib/ruby_ui/avatar/avatar_image.rb index bce164e31..aede5918a 100644 --- a/gem/lib/ruby_ui/avatar/avatar_image.rb +++ b/gem/lib/ruby_ui/avatar/avatar_image.rb @@ -17,7 +17,11 @@ def view_template def default_attrs { loading: "lazy", - class: "aspect-square h-full w-full", + data: { + ruby_ui__avatar_target: "image", + action: "load->ruby-ui--avatar#showImage error->ruby-ui--avatar#showFallback" + }, + class: "hidden aspect-square h-full w-full", alt: @alt, src: @src } diff --git a/gem/test/ruby_ui/avatar_test.rb b/gem/test/ruby_ui/avatar_test.rb index e4b8e5502..317b80b95 100644 --- a/gem/test/ruby_ui/avatar_test.rb +++ b/gem/test/ruby_ui/avatar_test.rb @@ -12,5 +12,10 @@ def test_render_with_all_items end assert_match(/joeldrapper/, output) + assert_match(/data-controller="ruby-ui--avatar"/, output) + assert_match(/data-ruby-ui--avatar-target="image"/, output) + assert_match(/load->ruby-ui--avatar#showImage error->ruby-ui--avatar#showFallback/, output) + assert_match(/data-ruby-ui--avatar-target="fallback"/, output) + assert_match(/hidden aspect-square/, output) end end From fe085c696646de83ee55cf7aec68dee695122079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Fri, 8 May 2026 09:59:39 -0300 Subject: [PATCH 2/2] fix(avatar): preserve image visibility without JS Image was hidden by default and only revealed by the Stimulus controller's load handler, which meant clients with JS disabled never saw the avatar image. Make the image visible by default and let the controller hide it on connect when it isn't yet loaded; load/error events restore the proper image/fallback toggle. --- gem/lib/ruby_ui/avatar/avatar_controller.js | 6 +++++- gem/lib/ruby_ui/avatar/avatar_image.rb | 2 +- gem/test/ruby_ui/avatar_test.rb | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/gem/lib/ruby_ui/avatar/avatar_controller.js b/gem/lib/ruby_ui/avatar/avatar_controller.js index 3643f26a7..e85748e01 100644 --- a/gem/lib/ruby_ui/avatar/avatar_controller.js +++ b/gem/lib/ruby_ui/avatar/avatar_controller.js @@ -4,11 +4,15 @@ export default class extends Controller { static targets = ["image", "fallback"]; connect() { - if (!this.hasImageTarget) return; + if (!this.hasImageTarget) { + return; + } if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) { this.showImage(); } else { + // Image not yet loaded (or failed): hide it so the fallback shows. + // Image visibility is restored by the load/error handlers. this.showFallback(); } } diff --git a/gem/lib/ruby_ui/avatar/avatar_image.rb b/gem/lib/ruby_ui/avatar/avatar_image.rb index aede5918a..3495f9795 100644 --- a/gem/lib/ruby_ui/avatar/avatar_image.rb +++ b/gem/lib/ruby_ui/avatar/avatar_image.rb @@ -21,7 +21,7 @@ def default_attrs ruby_ui__avatar_target: "image", action: "load->ruby-ui--avatar#showImage error->ruby-ui--avatar#showFallback" }, - class: "hidden aspect-square h-full w-full", + class: "aspect-square h-full w-full", alt: @alt, src: @src } diff --git a/gem/test/ruby_ui/avatar_test.rb b/gem/test/ruby_ui/avatar_test.rb index 317b80b95..9f38478cd 100644 --- a/gem/test/ruby_ui/avatar_test.rb +++ b/gem/test/ruby_ui/avatar_test.rb @@ -16,6 +16,7 @@ def test_render_with_all_items assert_match(/data-ruby-ui--avatar-target="image"/, output) assert_match(/load->ruby-ui--avatar#showImage error->ruby-ui--avatar#showFallback/, output) assert_match(/data-ruby-ui--avatar-target="fallback"/, output) - assert_match(/hidden aspect-square/, output) + assert_match(/class="aspect-square h-full w-full"/, output) + refute_match(/class="[^"]*\bhidden\b[^"]*aspect-square/, output) end end