From 97af21912cc2c1e7ab1e9be2f25fc17130695e89 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Thu, 7 May 2026 10:26:55 -0300 Subject: [PATCH 1/4] [Bug Fix] Prevent Command dialog stacking (#230) --- .../controllers/ruby_ui/command_controller.js | 13 +++++++++++++ gem/lib/ruby_ui/command/command_controller.js | 13 +++++++++++++ gem/lib/ruby_ui/command/command_dialog_content.rb | 2 +- gem/test/ruby_ui/command_test.rb | 1 + 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/app/javascript/controllers/ruby_ui/command_controller.js b/docs/app/javascript/controllers/ruby_ui/command_controller.js index 2ef0c47e9..0acf055dd 100644 --- a/docs/app/javascript/controllers/ruby_ui/command_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/command_controller.js @@ -1,6 +1,8 @@ import { Controller } from "@hotwired/stimulus"; import Fuse from "fuse.js"; +const OPEN_DIALOG_SELECTOR = "[data-ruby-ui--command-dialog]"; + // Connects to data-controller="ruby-ui--command" export default class extends Controller { static targets = ["input", "group", "item", "empty", "content"]; @@ -37,6 +39,12 @@ export default class extends Controller { return; } + const openDialog = document.querySelector(OPEN_DIALOG_SELECTOR); + if (openDialog) { + this.focusDialogInput(openDialog); + return; + } + document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML); // prevent scroll on body document.body.classList.add("overflow-hidden"); @@ -144,4 +152,9 @@ export default class extends Controller { this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false)); this.selectedIndex = -1; } + + focusDialogInput(dialog) { + const input = dialog.querySelector("[data-ruby-ui--command-target='input']"); + input?.focus(); + } } diff --git a/gem/lib/ruby_ui/command/command_controller.js b/gem/lib/ruby_ui/command/command_controller.js index 2ef0c47e9..0acf055dd 100644 --- a/gem/lib/ruby_ui/command/command_controller.js +++ b/gem/lib/ruby_ui/command/command_controller.js @@ -1,6 +1,8 @@ import { Controller } from "@hotwired/stimulus"; import Fuse from "fuse.js"; +const OPEN_DIALOG_SELECTOR = "[data-ruby-ui--command-dialog]"; + // Connects to data-controller="ruby-ui--command" export default class extends Controller { static targets = ["input", "group", "item", "empty", "content"]; @@ -37,6 +39,12 @@ export default class extends Controller { return; } + const openDialog = document.querySelector(OPEN_DIALOG_SELECTOR); + if (openDialog) { + this.focusDialogInput(openDialog); + return; + } + document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML); // prevent scroll on body document.body.classList.add("overflow-hidden"); @@ -144,4 +152,9 @@ export default class extends Controller { this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false)); this.selectedIndex = -1; } + + focusDialogInput(dialog) { + const input = dialog.querySelector("[data-ruby-ui--command-target='input']"); + input?.focus(); + } } diff --git a/gem/lib/ruby_ui/command/command_dialog_content.rb b/gem/lib/ruby_ui/command/command_dialog_content.rb index 5ada024f7..d180cb006 100644 --- a/gem/lib/ruby_ui/command/command_dialog_content.rb +++ b/gem/lib/ruby_ui/command/command_dialog_content.rb @@ -18,7 +18,7 @@ def initialize(size: :md, **attrs) def view_template(&block) template(data: {ruby_ui__command_target: "content"}) do - div(data: {controller: "ruby-ui--command"}) do + div(data: {controller: "ruby-ui--command", ruby_ui__command_dialog: true}) do backdrop div(**attrs, &block) end diff --git a/gem/test/ruby_ui/command_test.rb b/gem/test/ruby_ui/command_test.rb index 1a4011a97..d8743b295 100644 --- a/gem/test/ruby_ui/command_test.rb +++ b/gem/test/ruby_ui/command_test.rb @@ -60,5 +60,6 @@ def test_render_with_all_items end assert_match(/Search/, output) + assert_match(/data-ruby-ui--command-dialog/, output) end end From dda391d851b71a8820ae2c6b82247b168c788626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Fri, 8 May 2026 09:58:40 -0300 Subject: [PATCH 2/4] docs(command): note single-instance dialog behavior --- docs/app/views/docs/command.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/app/views/docs/command.rb b/docs/app/views/docs/command.rb index 37ce24cba..8049c0f4e 100644 --- a/docs/app/views/docs/command.rb +++ b/docs/app/views/docs/command.rb @@ -93,6 +93,12 @@ def view_template RUBY end + Heading(level: 2) { "Single instance" } + + p(class: "text-muted-foreground") do + plain "The Command dialog is single-instance. Activating a trigger while the dialog is already open refocuses the existing dialog instead of stacking another one on top, so repeated keybindings or trigger clicks behave predictably." + end + render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) From 7725ed74cf45516686dc61d308777993e2825e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Tue, 12 May 2026 11:14:04 -0300 Subject: [PATCH 3/4] refactor(command): track open dialog via static instance ref Replace querySelector + hardcoded selector with a static `openInstance` reference set in connect() and cleared in disconnect(). Removes the `data-ruby-ui--command-dialog` marker attribute and per-controller DOM lookups, so renaming the controller or restructuring dialog markup no longer silently breaks single-instance behavior. Addresses review feedback on #386. --- .../controllers/ruby_ui/command_controller.js | 22 ++++++++++++------- gem/lib/ruby_ui/command/command_controller.js | 22 ++++++++++++------- .../ruby_ui/command/command_dialog_content.rb | 2 +- gem/test/ruby_ui/command_test.rb | 2 +- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/docs/app/javascript/controllers/ruby_ui/command_controller.js b/docs/app/javascript/controllers/ruby_ui/command_controller.js index 0acf055dd..99a99a021 100644 --- a/docs/app/javascript/controllers/ruby_ui/command_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/command_controller.js @@ -1,8 +1,6 @@ import { Controller } from "@hotwired/stimulus"; import Fuse from "fuse.js"; -const OPEN_DIALOG_SELECTOR = "[data-ruby-ui--command-dialog]"; - // Connects to data-controller="ruby-ui--command" export default class extends Controller { static targets = ["input", "group", "item", "empty", "content"]; @@ -14,6 +12,8 @@ export default class extends Controller { }, }; + static openInstance = null; + connect() { this.selectedIndex = -1; @@ -21,6 +21,7 @@ export default class extends Controller { return; } + this.constructor.openInstance = this; this.inputTarget.focus(); this.searchIndex = this.buildSearchIndex(); this.toggleVisibility(this.emptyTargets, false); @@ -30,6 +31,12 @@ export default class extends Controller { } } + disconnect() { + if (this.constructor.openInstance === this) { + this.constructor.openInstance = null; + } + } + open(e) { if (e) { e.preventDefault(); @@ -39,9 +46,9 @@ export default class extends Controller { return; } - const openDialog = document.querySelector(OPEN_DIALOG_SELECTOR); - if (openDialog) { - this.focusDialogInput(openDialog); + const openInstance = this.constructor.openInstance; + if (openInstance) { + openInstance.focusInput(); return; } @@ -153,8 +160,7 @@ export default class extends Controller { this.selectedIndex = -1; } - focusDialogInput(dialog) { - const input = dialog.querySelector("[data-ruby-ui--command-target='input']"); - input?.focus(); + focusInput() { + this.inputTarget?.focus(); } } diff --git a/gem/lib/ruby_ui/command/command_controller.js b/gem/lib/ruby_ui/command/command_controller.js index 0acf055dd..99a99a021 100644 --- a/gem/lib/ruby_ui/command/command_controller.js +++ b/gem/lib/ruby_ui/command/command_controller.js @@ -1,8 +1,6 @@ import { Controller } from "@hotwired/stimulus"; import Fuse from "fuse.js"; -const OPEN_DIALOG_SELECTOR = "[data-ruby-ui--command-dialog]"; - // Connects to data-controller="ruby-ui--command" export default class extends Controller { static targets = ["input", "group", "item", "empty", "content"]; @@ -14,6 +12,8 @@ export default class extends Controller { }, }; + static openInstance = null; + connect() { this.selectedIndex = -1; @@ -21,6 +21,7 @@ export default class extends Controller { return; } + this.constructor.openInstance = this; this.inputTarget.focus(); this.searchIndex = this.buildSearchIndex(); this.toggleVisibility(this.emptyTargets, false); @@ -30,6 +31,12 @@ export default class extends Controller { } } + disconnect() { + if (this.constructor.openInstance === this) { + this.constructor.openInstance = null; + } + } + open(e) { if (e) { e.preventDefault(); @@ -39,9 +46,9 @@ export default class extends Controller { return; } - const openDialog = document.querySelector(OPEN_DIALOG_SELECTOR); - if (openDialog) { - this.focusDialogInput(openDialog); + const openInstance = this.constructor.openInstance; + if (openInstance) { + openInstance.focusInput(); return; } @@ -153,8 +160,7 @@ export default class extends Controller { this.selectedIndex = -1; } - focusDialogInput(dialog) { - const input = dialog.querySelector("[data-ruby-ui--command-target='input']"); - input?.focus(); + focusInput() { + this.inputTarget?.focus(); } } diff --git a/gem/lib/ruby_ui/command/command_dialog_content.rb b/gem/lib/ruby_ui/command/command_dialog_content.rb index d180cb006..5ada024f7 100644 --- a/gem/lib/ruby_ui/command/command_dialog_content.rb +++ b/gem/lib/ruby_ui/command/command_dialog_content.rb @@ -18,7 +18,7 @@ def initialize(size: :md, **attrs) def view_template(&block) template(data: {ruby_ui__command_target: "content"}) do - div(data: {controller: "ruby-ui--command", ruby_ui__command_dialog: true}) do + div(data: {controller: "ruby-ui--command"}) do backdrop div(**attrs, &block) end diff --git a/gem/test/ruby_ui/command_test.rb b/gem/test/ruby_ui/command_test.rb index d8743b295..63323082e 100644 --- a/gem/test/ruby_ui/command_test.rb +++ b/gem/test/ruby_ui/command_test.rb @@ -60,6 +60,6 @@ def test_render_with_all_items end assert_match(/Search/, output) - assert_match(/data-ruby-ui--command-dialog/, output) + assert_match(/data-controller="ruby-ui--command"/, output) end end From af843637d7a2ca3804a4e11af734a1b3b809fb53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Tue, 12 May 2026 13:18:38 -0300 Subject: [PATCH 4/4] refactor(command): split dialog controller, use Stimulus outlets Introduce ruby-ui--command-dialog controller for the trigger/template wrapper and keep ruby-ui--command for the cloned dialog instance. The trigger declares a ruby-ui--command outlet matched by a marker attribute on the cloned dialog wrapper. Outlet connect/disconnect callbacks track the active dialog, replacing the static class field and avoiding both querySelector and same-identifier dual-personality controller code. - New: command_dialog_controller.js (trigger + content target + outlet) - Strip open/openValue/content target from command_controller.js - Rename trigger actions to ruby-ui--command-dialog#open - Add ruby_ui__command_dialog_instance marker on cloned dialog --- docs/app/javascript/controllers/index.js | 3 ++ .../controllers/ruby_ui/command_controller.js | 50 ++----------------- .../ruby_ui/command_dialog_controller.js | 34 +++++++++++++ gem/lib/ruby_ui/command/command_controller.js | 50 ++----------------- gem/lib/ruby_ui/command/command_dialog.rb | 5 +- .../ruby_ui/command/command_dialog_content.rb | 4 +- .../command/command_dialog_controller.js | 34 +++++++++++++ .../ruby_ui/command/command_dialog_trigger.rb | 4 +- 8 files changed, 89 insertions(+), 95 deletions(-) create mode 100644 docs/app/javascript/controllers/ruby_ui/command_dialog_controller.js create mode 100644 gem/lib/ruby_ui/command/command_dialog_controller.js diff --git a/docs/app/javascript/controllers/index.js b/docs/app/javascript/controllers/index.js index e68815bd9..8857f2046 100644 --- a/docs/app/javascript/controllers/index.js +++ b/docs/app/javascript/controllers/index.js @@ -40,6 +40,9 @@ application.register("ruby-ui--combobox", RubyUi__ComboboxController) import RubyUi__CommandController from "./ruby_ui/command_controller" application.register("ruby-ui--command", RubyUi__CommandController) +import RubyUi__CommandDialogController from "./ruby_ui/command_dialog_controller" +application.register("ruby-ui--command-dialog", RubyUi__CommandDialogController) + import RubyUi__ContextMenuController from "./ruby_ui/context_menu_controller" application.register("ruby-ui--context-menu", RubyUi__ContextMenuController) diff --git a/docs/app/javascript/controllers/ruby_ui/command_controller.js b/docs/app/javascript/controllers/ruby_ui/command_controller.js index 99a99a021..91b80495d 100644 --- a/docs/app/javascript/controllers/ruby_ui/command_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/command_controller.js @@ -3,16 +3,7 @@ import Fuse from "fuse.js"; // Connects to data-controller="ruby-ui--command" export default class extends Controller { - static targets = ["input", "group", "item", "empty", "content"]; - - static values = { - open: { - type: Boolean, - default: false, - }, - }; - - static openInstance = null; + static targets = ["input", "group", "item", "empty"]; connect() { this.selectedIndex = -1; @@ -21,40 +12,9 @@ export default class extends Controller { return; } - this.constructor.openInstance = this; this.inputTarget.focus(); this.searchIndex = this.buildSearchIndex(); this.toggleVisibility(this.emptyTargets, false); - - if (this.openValue && this.hasContentTarget) { - this.open(); - } - } - - disconnect() { - if (this.constructor.openInstance === this) { - this.constructor.openInstance = null; - } - } - - open(e) { - if (e) { - e.preventDefault(); - } - - if (!this.hasContentTarget) { - return; - } - - const openInstance = this.constructor.openInstance; - if (openInstance) { - openInstance.focusInput(); - return; - } - - document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML); - // prevent scroll on body - document.body.classList.add("overflow-hidden"); } dismiss() { @@ -64,6 +24,10 @@ export default class extends Controller { this.element.remove(); } + focusInput() { + this.inputTarget?.focus(); + } + filter(e) { // Deselect any previously selected item this.deselectAll(); @@ -159,8 +123,4 @@ export default class extends Controller { this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false)); this.selectedIndex = -1; } - - focusInput() { - this.inputTarget?.focus(); - } } diff --git a/docs/app/javascript/controllers/ruby_ui/command_dialog_controller.js b/docs/app/javascript/controllers/ruby_ui/command_dialog_controller.js new file mode 100644 index 000000000..7763a79e1 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/command_dialog_controller.js @@ -0,0 +1,34 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="ruby-ui--command-dialog" +export default class extends Controller { + static targets = ["content"]; + static outlets = ["ruby-ui--command"]; + + rubyUiCommandOutletConnected(controller) { + this.openOutlet = controller; + } + + rubyUiCommandOutletDisconnected() { + this.openOutlet = null; + } + + open(e) { + if (e) { + e.preventDefault(); + } + + if (!this.hasContentTarget) { + return; + } + + if (this.openOutlet) { + this.openOutlet.focusInput(); + return; + } + + document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML); + // prevent scroll on body + document.body.classList.add("overflow-hidden"); + } +} diff --git a/gem/lib/ruby_ui/command/command_controller.js b/gem/lib/ruby_ui/command/command_controller.js index 99a99a021..91b80495d 100644 --- a/gem/lib/ruby_ui/command/command_controller.js +++ b/gem/lib/ruby_ui/command/command_controller.js @@ -3,16 +3,7 @@ import Fuse from "fuse.js"; // Connects to data-controller="ruby-ui--command" export default class extends Controller { - static targets = ["input", "group", "item", "empty", "content"]; - - static values = { - open: { - type: Boolean, - default: false, - }, - }; - - static openInstance = null; + static targets = ["input", "group", "item", "empty"]; connect() { this.selectedIndex = -1; @@ -21,40 +12,9 @@ export default class extends Controller { return; } - this.constructor.openInstance = this; this.inputTarget.focus(); this.searchIndex = this.buildSearchIndex(); this.toggleVisibility(this.emptyTargets, false); - - if (this.openValue && this.hasContentTarget) { - this.open(); - } - } - - disconnect() { - if (this.constructor.openInstance === this) { - this.constructor.openInstance = null; - } - } - - open(e) { - if (e) { - e.preventDefault(); - } - - if (!this.hasContentTarget) { - return; - } - - const openInstance = this.constructor.openInstance; - if (openInstance) { - openInstance.focusInput(); - return; - } - - document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML); - // prevent scroll on body - document.body.classList.add("overflow-hidden"); } dismiss() { @@ -64,6 +24,10 @@ export default class extends Controller { this.element.remove(); } + focusInput() { + this.inputTarget?.focus(); + } + filter(e) { // Deselect any previously selected item this.deselectAll(); @@ -159,8 +123,4 @@ export default class extends Controller { this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false)); this.selectedIndex = -1; } - - focusInput() { - this.inputTarget?.focus(); - } } diff --git a/gem/lib/ruby_ui/command/command_dialog.rb b/gem/lib/ruby_ui/command/command_dialog.rb index c6a4b41f5..b8f59d50f 100644 --- a/gem/lib/ruby_ui/command/command_dialog.rb +++ b/gem/lib/ruby_ui/command/command_dialog.rb @@ -10,7 +10,10 @@ def view_template(&) def default_attrs { - data: {controller: "ruby-ui--command"} + data: { + controller: "ruby-ui--command-dialog", + ruby_ui__command_dialog_ruby_ui__command_outlet: "[data-ruby-ui--command-dialog-instance]" + } } end end diff --git a/gem/lib/ruby_ui/command/command_dialog_content.rb b/gem/lib/ruby_ui/command/command_dialog_content.rb index 5ada024f7..0781b6e41 100644 --- a/gem/lib/ruby_ui/command/command_dialog_content.rb +++ b/gem/lib/ruby_ui/command/command_dialog_content.rb @@ -17,8 +17,8 @@ def initialize(size: :md, **attrs) end def view_template(&block) - template(data: {ruby_ui__command_target: "content"}) do - div(data: {controller: "ruby-ui--command"}) do + template(data: {ruby_ui__command_dialog_target: "content"}) do + div(data: {controller: "ruby-ui--command", ruby_ui__command_dialog_instance: true}) do backdrop div(**attrs, &block) end diff --git a/gem/lib/ruby_ui/command/command_dialog_controller.js b/gem/lib/ruby_ui/command/command_dialog_controller.js new file mode 100644 index 000000000..7763a79e1 --- /dev/null +++ b/gem/lib/ruby_ui/command/command_dialog_controller.js @@ -0,0 +1,34 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="ruby-ui--command-dialog" +export default class extends Controller { + static targets = ["content"]; + static outlets = ["ruby-ui--command"]; + + rubyUiCommandOutletConnected(controller) { + this.openOutlet = controller; + } + + rubyUiCommandOutletDisconnected() { + this.openOutlet = null; + } + + open(e) { + if (e) { + e.preventDefault(); + } + + if (!this.hasContentTarget) { + return; + } + + if (this.openOutlet) { + this.openOutlet.focusInput(); + return; + } + + document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML); + // prevent scroll on body + document.body.classList.add("overflow-hidden"); + } +} diff --git a/gem/lib/ruby_ui/command/command_dialog_trigger.rb b/gem/lib/ruby_ui/command/command_dialog_trigger.rb index 9c12eb66e..d3c5858c6 100644 --- a/gem/lib/ruby_ui/command/command_dialog_trigger.rb +++ b/gem/lib/ruby_ui/command/command_dialog_trigger.rb @@ -8,7 +8,7 @@ class CommandDialogTrigger < Base ].freeze def initialize(keybindings: DEFAULT_KEYBINDINGS, **attrs) - @keybindings = keybindings.map { |kb| "#{kb}->ruby-ui--command#open" } + @keybindings = keybindings.map { |kb| "#{kb}->ruby-ui--command-dialog#open" } super(**attrs) end @@ -21,7 +21,7 @@ def view_template(&) def default_attrs { data: { - action: ["click->ruby-ui--command#open", @keybindings.join(" ")] + action: ["click->ruby-ui--command-dialog#open", @keybindings.join(" ")] } } end