@@ -125,6 +125,34 @@ def active_element
125125 page . evaluate_script ( "document.activeElement" )
126126 end
127127
128+ def assert_anchored_above_invoker
129+ attempts = 0
130+ max_attempts = 3
131+
132+ begin
133+ attempts += 1
134+
135+ # Distance between the dialog's bottom edge and the invoker's top edge.
136+ # When properly anchored above the trigger this is the anchor offset
137+ # (a few pixels); when the dialog floats away it is much larger.
138+ gap = page . evaluate_script ( <<~JS )
139+ (() => {
140+ const invoker = document.querySelector('select-panel button[aria-controls]')
141+ const dialog = document.querySelector('select-panel dialog')
142+ const invokerRect = invoker.getBoundingClientRect()
143+ const dialogRect = dialog.getBoundingClientRect()
144+ return Math.abs(invokerRect.top - dialogRect.bottom)
145+ })()
146+ JS
147+
148+ assert_operator gap , :<= , 16 , "Expected dialog to remain anchored above its trigger, but it was #{ gap } px away"
149+ rescue Minitest ::Assertion => e
150+ raise e if attempts >= max_attempts
151+ sleep 1
152+ retry
153+ end
154+ end
155+
128156 ########## TESTS ############
129157
130158 def test_invoker_opens_panel
@@ -225,6 +253,39 @@ def test_remembers_selections_on_filter
225253 assert_selector "[aria-selected=true]" , count : 2
226254 end
227255
256+ def test_dialog_stays_anchored_to_invoker_when_content_size_changes
257+ visit_preview ( :remote_fetch )
258+
259+ # Anchor the panel above its trigger and push the trigger down the page so
260+ # there is room above it. When anchored to outside-top the computed
261+ # position depends on the dialog's height, so a change in content size
262+ # must trigger a re-anchor.
263+ page . execute_script ( <<~JS )
264+ const panel = document.querySelector('select-panel')
265+ panel.setAttribute('anchor-side', 'outside-top')
266+ panel.style.display = 'inline-block'
267+ panel.style.marginTop = '600px'
268+ JS
269+
270+ wait_for_items_to_load do
271+ click_on_invoker_button
272+ end
273+
274+ # Panel opens anchored above its trigger.
275+ assert_anchored_above_invoker
276+
277+ # Change the dialog's rendered size after it has been positioned. Without
278+ # re-anchoring on size changes, the dialog keeps its original top and
279+ # floats away from the trigger.
280+ page . execute_script ( <<~JS )
281+ const dialog = document.querySelector('select-panel dialog')
282+ dialog.style.minHeight = '0'
283+ dialog.style.height = '150px'
284+ JS
285+
286+ assert_anchored_above_invoker
287+ end
288+
228289 def test_pressing_down_arrow_in_filter_input_focuses_first_item
229290 visit_preview ( :default )
230291
0 commit comments