Files
sure/test/system/ds_overlay_morph_test.rb
Guillem Arias Fauste 07a3413250 fix(ds): keep DS::Menu/Popover panels anchored across Turbo morphs (#2812)
* fix(ds): keep DS::Menu/Popover panels anchored across Turbo morphs

The app refreshes pages via Turbo morph (`turbo_refreshes_with method:
:morph`), and same-page account actions (disable, exclude, set-default,
etc.) trigger one. Two bugs in the shared floating-ui controllers surface
as a result:

- The panel's `position: fixed` only ever existed as a JS-applied inline
  style. Idiomorph resets every menu/popover's `style` attribute to match
  the server-rendered markup (which has none), silently stripping
  `position: fixed` from every panel on the page. The next dropdown/
  popover opened before floating-ui's async recompute lands briefly
  renders in normal flex flow, shoving its own trigger sideways and
  making computePosition anchor to that phantom position instead of the
  real button. Fix: make `position: fixed` part of the static markup so
  it can never be stripped.

- `this.show` was a plain instance property. Because the morph preserves
  the Stimulus controller in place (stable-id turbo frame), `this.show`
  doesn't reset when idiomorph re-closes the content element, so it can
  desync from the DOM and swallow the next click. Fix: derive `show` from
  the content element's own class instead of tracking it separately.

Reproduced and verified against the actual Turbo/Stimulus/floating-ui
pipeline in an isolated harness before and after the fix.

* test(ds): add regression coverage for menu/popover reopen-after-morph

Simulates what idiomorph does to an open panel on a same-page Turbo
morph — resets the content element's class back to the always-hidden
server-rendered markup and strips the JS-applied inline style, without
going through toggle()/close(). Verified against the pre-fix controllers
that this fails without the DOM-derived `show` getter.
2026-07-30 02:54:42 +02:00

66 lines
2.3 KiB
Ruby

require "application_system_test_case"
# Regression coverage for the bug fixed alongside DS::Menu / DS::Popover's
# `fixed`-in-static-class change: a Turbo morph re-renders an open panel's
# content element back to its server-rendered (closed) state without going
# through the controller's own open/close methods. `show` used to be a plain
# instance property that a morph couldn't touch, so the next click toggled
# off an already-closed panel — swallowing the click. Deriving `show` from
# the content element's own `hidden` class (rather than tracked state) means
# it can't desync from what the DOM actually shows.
class DsOverlayMorphTest < ApplicationSystemTestCase
setup do
sign_in @user = users(:family_admin)
end
test "DS::Menu reopens correctly after a morph resets it to hidden" do
visit tags_url
trigger = find("[aria-haspopup='menu']", match: :first)
content_id = trigger["aria-controls"]
trigger.click
assert_selector "##{content_id}", visible: true
assert_equal "true", trigger["aria-expanded"]
simulate_morph_reset(content_id)
trigger.click
assert_selector "##{content_id}", visible: true
assert_equal "true", trigger["aria-expanded"]
end
test "DS::Popover reopens correctly after a morph resets it to hidden" do
visit root_url
within_testid "user-menu" do
trigger = find("[aria-haspopup='dialog']")
content_id = trigger["aria-controls"]
trigger.click
assert_selector "##{content_id}", visible: true
assert_equal "true", trigger["aria-expanded"]
simulate_morph_reset(content_id)
trigger.click
assert_selector "##{content_id}", visible: true
assert_equal "true", trigger["aria-expanded"]
end
end
private
# Mimics what idiomorph does to an open panel on a same-page Turbo morph:
# the content element's class list is reconciled back to the always-hidden
# server-rendered markup, and any inline style the controller applied
# (position coordinates) is stripped — all without calling the Stimulus
# controller's toggle()/close().
def simulate_morph_reset(content_id)
page.execute_script(<<~JS, content_id)
const el = document.getElementById(arguments[0]);
el.classList.add("hidden");
el.removeAttribute("style");
JS
end
end