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
75 changes: 67 additions & 8 deletions gem/lib/ruby_ui/clipboard/clipboard_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,24 @@ import { computePosition, flip, shift } from "@floating-ui/dom";

// Connects to data-controller="accordion"
export default class extends Controller {
static targets = ['trigger', 'source', 'successPopover', 'errorPopover']
static targets = ['trigger', 'source', 'successPopover', 'successPanel', 'errorPopover', 'errorPanel']
static values = {
options: {
type: Object,
default: {},
},
}

disconnect() {
// Nothing is left to wait for the exit animation, so apply the pending hide now.
if (this.hasSuccessPanelTarget) this.settleExit(this.successPanelTarget);
if (this.hasErrorPanelTarget) this.settleExit(this.errorPanelTarget);
}

copy() {
let sourceElement = this.sourceTarget.children[0];
if (!sourceElement) {
this.showErrorPopover();
this.#showErrorPopover();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the clipboard has no source child, this synchronous fallback shows the error during the trigger click, but the same click bubbles to the window handler and immediately starts closing it. Defer the fallback until after event propagation or make the outside handler ignore clicks inside the controller so the error remains visible.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/clipboard/clipboard_controller.js, line 23:

<comment>When the clipboard has no source child, this synchronous fallback shows the error during the trigger click, but the same click bubbles to the window handler and immediately starts closing it. Defer the fallback until after event propagation or make the outside handler ignore clicks inside the controller so the error remains visible.</comment>

<file context>
@@ -3,18 +3,24 @@ import { computePosition, flip, shift } from "@floating-ui/dom";
     let sourceElement = this.sourceTarget.children[0];
     if (!sourceElement) {
-      this.showErrorPopover();
+      this.#showErrorPopover();
       return;
     }
</file context>
Suggested change
this.#showErrorPopover();
queueMicrotask(() => this.#showErrorPopover());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — the fallback ran inside the trigger's click, and the same click reached click@window and closed it again (before this PR the typo threw instead, so nothing showed at all). A microtask would not help: it runs before the event reaches window. The fix is on the other side in d41dd90: onClickOutside now ignores clicks inside the controller's element, the rule popover_controller.js already applies (:82). Verified: with no source child the error popover stays up after the trigger click and closes on the next outside click.

return;
}
let textToCopy = sourceElement.tagName === 'INPUT' ? sourceElement.value : sourceElement.innerText;
Expand All @@ -26,8 +32,8 @@ export default class extends Controller {
}

onClickOutside() {
if (!this.successPopoverTarget.classList.contains("hidden")) this.successPopoverTarget.classList.add("hidden");
if (!this.errorPopoverTarget.classList.contains("hidden")) this.errorPopoverTarget.classList.add("hidden");
this.#hidePopover(this.successPopoverTarget, this.successPanelTarget);
this.#hidePopover(this.errorPopoverTarget, this.errorPanelTarget);
}

#computeTooltip(popoverElement) {
Expand All @@ -43,12 +49,65 @@ export default class extends Controller {
}

#showSuccessPopover() {
this.#computeTooltip(this.successPopoverTarget);
this.successPopoverTarget.classList.remove("hidden");
this.#showPopover(this.successPopoverTarget, this.successPanelTarget);
}

#showErrorPopover() {
this.#computeTooltip(this.errorPopoverTarget);
this.errorPopoverTarget.classList.remove("hidden");
this.#showPopover(this.errorPopoverTarget, this.errorPanelTarget);
}

#showPopover(popover, panel) {
this.#computeTooltip(popover);
popover.classList.remove("hidden");
panel.dataset.state = "open";
}

#hidePopover(popover, panel) {
if (popover.classList.contains("hidden")) return;

panel.dataset.state = "closed";
this.hideAfterExitAnimation(panel);
}

afterExit(panel) {
const popover = panel === this.successPanelTarget ? this.successPopoverTarget : this.errorPopoverTarget;
popover.classList.add("hidden");
}

// Overlay exit — the same block in every overlay controller, so keep them in sync.
exitAnimationNames = new WeakMap();

hideAfterExitAnimation(animated) {
const exitAnimations = animated
.getAnimations()
.filter((animation) => animation instanceof CSSAnimation);

// No exit animation, or no box to run it in: animationend would never fire.
if (exitAnimations.length === 0) {
this.settleExit(animated);
return;
}

this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));
animated.addEventListener("animationend", this.handleExitAnimationEnd);
animated.addEventListener("animationcancel", this.handleExitAnimationEnd);
}

handleExitAnimationEnd = (event) => {
// animationend bubbles — an animated child must not hide its container.
if (event.target !== event.currentTarget) return;
// Closing mid-open cancels the enter animation; only the exit run settles this.
if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;

this.settleExit(event.currentTarget);
};

settleExit(animated) {
animated.removeEventListener("animationend", this.handleExitAnimationEnd);
animated.removeEventListener("animationcancel", this.handleExitAnimationEnd);
// Reopened mid-exit: it is on its way back in, leave it visible.
if (animated.dataset.state !== "closed") return;

this.afterExit(animated);
}
}
14 changes: 12 additions & 2 deletions gem/lib/ruby_ui/clipboard/clipboard_popover.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,22 @@ def clipboard_target
end
end

def panel_target
case @type
when :success
"successPanel"
when :error
"errorPanel"
end
end

def default_attrs
{
data: {
state: :open
state: :open,
ruby_ui__clipboard_target: panel_target
},
class: "z-50 rounded-md text-sm border bg-background px-2 py-0.5 text-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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"
class: "z-50 rounded-md text-sm border bg-background px-2 py-0.5 text-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=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"
}
end
end
Expand Down
60 changes: 57 additions & 3 deletions gem/lib/ruby_ui/command/command_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +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"];
static targets = ["input", "group", "item", "empty", "backdrop", "panel"];

connect() {
this.selectedIndex = -1;
Expand All @@ -17,13 +17,67 @@ export default class extends Controller {
this.toggleVisibility(this.emptyTargets, false);
}

disconnect() {
// Nothing is left to wait for the exit animation, so apply the pending removal now.
if (this.hasPanelTarget) this.settleExit(this.panelTarget);
}

dismiss() {
// allow scroll on body
this.backdropTarget.dataset.state = "closed";
this.panelTarget.dataset.state = "closed";
this.hideAfterExitAnimation(this.panelTarget);
}

// Opened again while dismissing: bring this instance back instead of stacking a new one.
show() {
this.backdropTarget.dataset.state = "open";
this.panelTarget.dataset.state = "open";
document.body.classList.add("overflow-hidden");
this.focusInput();
}

afterExit() {
document.body.classList.remove("overflow-hidden");
// remove the element
this.element.remove();
}

// Overlay exit — the same block in every overlay controller, so keep them in sync.
exitAnimationNames = new WeakMap();

hideAfterExitAnimation(animated) {
const exitAnimations = animated
.getAnimations()
.filter((animation) => animation instanceof CSSAnimation);

// No exit animation, or no box to run it in: animationend would never fire.
if (exitAnimations.length === 0) {
this.settleExit(animated);
return;
}

this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));
animated.addEventListener("animationend", this.handleExitAnimationEnd);
animated.addEventListener("animationcancel", this.handleExitAnimationEnd);
}

handleExitAnimationEnd = (event) => {
// animationend bubbles — an animated child must not hide its container.
if (event.target !== event.currentTarget) return;
// Closing mid-open cancels the enter animation; only the exit run settles this.
if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;

this.settleExit(event.currentTarget);
};

settleExit(animated) {
animated.removeEventListener("animationend", this.handleExitAnimationEnd);
animated.removeEventListener("animationcancel", this.handleExitAnimationEnd);
// Reopened mid-exit: it is on its way back in, leave it visible.
if (animated.dataset.state !== "closed") return;

this.afterExit(animated);
}

focusInput() {
this.inputTarget?.focus();
}
Expand Down
6 changes: 4 additions & 2 deletions gem/lib/ruby_ui/command/command_dialog_content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ def view_template(&block)
def default_attrs
{
data_state: "open",
data_ruby_ui__command_target: "panel",
class: [
"fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
"fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:fill-mode-forwards data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
SIZES[@size]
]
}
Expand All @@ -41,7 +42,8 @@ def backdrop
div(
data_state: "open",
data_action: "click->ruby-ui--command#dismiss esc->ruby-ui--command#dismiss",
class: "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
data_ruby_ui__command_target: "backdrop",
class: "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:fill-mode-forwards"
)
end
end
Expand Down
2 changes: 1 addition & 1 deletion gem/lib/ruby_ui/command/command_dialog_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default class extends Controller {
}

if (this.openOutlet) {
this.openOutlet.focusInput();
this.openOutlet.show();
return;
}

Expand Down
2 changes: 1 addition & 1 deletion gem/lib/ruby_ui/context_menu/context_menu_content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def default_attrs
data_state: "closed",
data: {ruby_ui__context_menu_target: "content"},
class:
"hidden absolute z-50 min-w-[8rem] outline-none pointer-events-auto overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
"hidden absolute z-50 min-w-[8rem] outline-none pointer-events-auto overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=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",
tabindex: "-1",
data_orientation: "vertical"
}
Expand Down
50 changes: 48 additions & 2 deletions gem/lib/ruby_ui/context_menu/context_menu_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export default class extends Controller {

disconnect() {
this.hide();
// Nothing is left to wait for the exit animation, so apply the pending hide now.
if (this.hasContentTarget) this.settleExit(this.contentTarget);
}

handleContextMenu(event) {
Expand All @@ -49,14 +51,58 @@ export default class extends Controller {
hide() {
if (!this.openValue) return;
this.openValue = false;
this.contentTarget.classList.add("hidden");
this.contentTarget.dataset.state = "closed";
this.removeEventListeners();
this.deselectAll();
if (this.cleanup) {
this.cleanup();
this.cleanup = null;
}

if (!this.hasContentTarget) return;

this.contentTarget.dataset.state = "closed";
this.hideAfterExitAnimation(this.contentTarget);
}

afterExit(content) {
content.classList.add("hidden");
}

// Overlay exit — the same block in every overlay controller, so keep them in sync.
exitAnimationNames = new WeakMap();

hideAfterExitAnimation(animated) {
const exitAnimations = animated
.getAnimations()
.filter((animation) => animation instanceof CSSAnimation);

// No exit animation, or no box to run it in: animationend would never fire.
if (exitAnimations.length === 0) {
this.settleExit(animated);
return;
}

this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));
animated.addEventListener("animationend", this.handleExitAnimationEnd);
animated.addEventListener("animationcancel", this.handleExitAnimationEnd);
}

handleExitAnimationEnd = (event) => {
// animationend bubbles — an animated child must not hide its container.
if (event.target !== event.currentTarget) return;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// Closing mid-open cancels the enter animation; only the exit run settles this.
if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;

this.settleExit(event.currentTarget);
};

settleExit(animated) {
animated.removeEventListener("animationend", this.handleExitAnimationEnd);
animated.removeEventListener("animationcancel", this.handleExitAnimationEnd);
// Reopened mid-exit: it is on its way back in, leave it visible.
if (animated.dataset.state !== "closed") return;

this.afterExit(animated);
}

updatePosition() {
Expand Down
5 changes: 3 additions & 2 deletions gem/lib/ruby_ui/dropdown_menu/dropdown_menu_content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ def view_template(&block)
def default_attrs
{
data: {
state: :open
state: :open,
ruby_ui__dropdown_menu_target: "panel"
},
class: "z-50 min-w-[8rem] rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 w-56"
class: "z-50 min-w-[8rem] rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=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 w-56"
}
end

Expand Down
Loading