ruby-ui / ruby-ui/ruby_ui

Popover: data-state/data-side never set (animation classes are dead CSS), closeTimeout leaks past disconnect, no Escape

未关闭
#494 0 条评论 0 个 reaction 已指派 1 人 在 GitHub 查看

@djalmaaraujo 已经在做这个了。

开始于 2026年7月30日。

主要语言
Ruby
星标
1k
派生
67
平均合并
3 天 12 小时
30 天内合并 PR
14

描述

Summary

Popover was never brought up to the standard its closest sibling HoverCard got in #438 (the Popper → Floating UI port). popover_controller.js is untouched since the monorepo unification (#358), while hover_card_controller.js gained state attributes, side-aware positioning, timer cleanup and keyboard dismissal.

The result is that PopoverContent ships eight Tailwind variant classes that can never match, plus a timer that can outlive the controller.

1. data-state is never set — the enter/exit animation classes are dead CSS

gem/lib/ruby_ui/popover/popover_content.rb declares:

"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",

But popover_controller.js only toggles the hidden class:

showPopover() {
  this.contentTarget.classList.remove("hidden");
  this.updatePosition();
}

hidePopover() {
  this.contentTarget.classList.add("hidden");
  if (this.cleanup) {
    this.cleanup();
  }
}

Nothing ever writes data-state on the content element, and unlike HoverCardContent (which server-renders data: { ..., state: :closed }), PopoverContent doesn't even carry an initial value. So the attribute simply never exists and the popover appears/disappears with no transition — visually inconsistent with HoverCard, DropdownMenu and Tooltip, which all animate.

hover_card_controller.js is the reference:

show() {
  this.openValue = true;
  this.contentTarget.classList.remove("hidden");
  this.contentTarget.dataset.state = "open";
  // ...
}

hide() {
  this.openValue = false;
  this.contentTarget.classList.add("hidden");
  this.contentTarget.dataset.state = "closed";
  // ...
}

2. data-side is never set — the directional slide classes are dead CSS too

Same root cause, one layer further. popover_content.rb also declares:

"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",

updatePosition() destructures only { x, y } from computePosition and throws away the resolved placement:

computePosition(this.triggerTarget, this.contentTarget, {
  placement: this.optionsValue.placement || "bottom",
  middleware: [flip(), shift(), offset(8)],
}).then(({ x, y }) => {
  Object.assign(this.contentTarget.style, { left: `${x}px`, top: `${y}px` });
});

The resolved placement matters here precisely because flip() is in the middleware chain — the popover can end up on the opposite side of the requested one, and only the resolved value is correct. hover_card_controller.js already does this:

}).then(({ x, y, placement }) => {
  Object.assign(this.contentTarget.style, { left: `${x}px`, top: `${y}px` });
  this.contentTarget.dataset.side = placement.split("-")[0];
});

3. closeTimeout survives disconnect()

handleMouseLeave schedules a 100 ms close:

handleMouseLeave = () => {
  this.closeTimeout = setTimeout(() => {
    this.openValue = false;
    this.hidePopover();
  }, 100);
};

disconnect() never clears it:

disconnect() {
  this.removeEventListeners();
  if (this.cleanup) {
    this.cleanup();
  }
}

If the element is disconnected inside that 100 ms window and reconnected — a Turbo frame or stream replacement, a morph, an element moved in the DOM — Stimulus builds a fresh controller instance while the old instance's timeout is still pending. The stale callback resolves this.contentTarget to the same, re-attached element and hides it, so a popover the new instance has just opened snaps shut on its own. There is no console error to go on; the symptom is a popover that closes for no visible reason.

hover_card_controller.js clears its timers on disconnect (clearTimers()), and also nulls out this.cleanup after invoking it, which the popover doesn't — so a stale autoUpdate cleanup stays referenced.

4. No keyboard dismissal

HoverCard and DropdownMenu both close on Escape. Popover has no keydown handling at all, so with options: { trigger: "click" } a keyboard user can open the popover and has no way to dismiss it — the only close paths are an outside mouse click or a second click on the trigger.

Proposed fix

Bring Popover to parity with HoverCard, mirroring its implementation rather than inventing a new approach:

  • server-render data-state="closed" on PopoverContent, and set dataset.state to open/closed in showPopover/hidePopover;
  • read placement back out of computePosition and set dataset.side from it;
  • clear closeTimeout in disconnect(), and null this.cleanup after calling it;
  • close on Escape, with the document keydown listener added on open and removed on close/disconnect.

Both copies of the controller (gem/lib/ruby_ui/popover/popover_controller.js and docs/app/javascript/controllers/ruby_ui/popover_controller.js) need the change — they are currently byte-identical and drifting them apart is what #490 had to fix for Accordion.

Known limitation, deliberately out of scope

Because hidden (display: none) is applied in the same frame as data-state="closed", the animate-out exit animation still won't be visible — the element is removed from the render tree before the animation can run. HoverCard and DropdownMenu have the identical limitation; only Tooltip handles it, via an animationend listener that defers unmounting. Making the exit animation actually play is a broader change across all the hidden-toggling floating components and should be its own issue, so this one stops at the enter animation, the side-aware slide, and the correctness bugs.

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

评估

这个 Issue 还没有评估数据。

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。