Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/select-panel-anchor-on-resize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/view-components": patch
---

Fix `SelectPanel` dialog floating away from its trigger when its content size changes (e.g. after remote content loads), particularly when anchored with `anchor_side: :outside_top`
6 changes: 6 additions & 0 deletions app/components/primer/alpha/select_panel_element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,15 @@ const updateWhenVisible = (() => {
el.addEventListener('dialog:close', () => {
el.invokerElement?.setAttribute('aria-expanded', 'false')
anchors.delete(el)
if (el.dialog) resizeObserver?.unobserve(el.dialog)
})
el.addEventListener('dialog:open', () => {
anchors.add(el)
// Re-anchor the dialog whenever its own size changes (e.g. after remote
// content finishes loading or items are filtered). Otherwise the dialog
// can float away from its trigger, particularly when anchored to a side
// whose position depends on the dialog's height (e.g. outside-top).
if (el.dialog) resizeObserver?.observe(el.dialog)
})
}
})()
Expand Down
61 changes: 61 additions & 0 deletions test/system/alpha/select_panel_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,34 @@ def active_element
page.evaluate_script("document.activeElement")
end

def assert_anchored_above_invoker
attempts = 0
max_attempts = 3

begin
attempts += 1

# Distance between the dialog's bottom edge and the invoker's top edge.
# When properly anchored above the trigger this is the anchor offset
# (a few pixels); when the dialog floats away it is much larger.
gap = page.evaluate_script(<<~JS)
(() => {
const invoker = document.querySelector('select-panel button[aria-controls]')
const dialog = document.querySelector('select-panel dialog')
const invokerRect = invoker.getBoundingClientRect()
const dialogRect = dialog.getBoundingClientRect()
return Math.abs(invokerRect.top - dialogRect.bottom)
})()
JS

assert_operator gap, :<=, 16, "Expected dialog to remain anchored above its trigger, but it was #{gap}px away"
rescue Minitest::Assertion => e
raise e if attempts >= max_attempts
sleep 1
retry
end
end

########## TESTS ############

def test_invoker_opens_panel
Expand Down Expand Up @@ -225,6 +253,39 @@ def test_remembers_selections_on_filter
assert_selector "[aria-selected=true]", count: 2
end

def test_dialog_stays_anchored_to_invoker_when_content_size_changes
visit_preview(:remote_fetch)

# Anchor the panel above its trigger and push the trigger down the page so
# there is room above it. When anchored to outside-top the computed
# position depends on the dialog's height, so a change in content size
# must trigger a re-anchor.
page.execute_script(<<~JS)
const panel = document.querySelector('select-panel')
panel.setAttribute('anchor-side', 'outside-top')
panel.style.display = 'inline-block'
panel.style.marginTop = '600px'
JS

wait_for_items_to_load do
click_on_invoker_button
end

# Panel opens anchored above its trigger.
assert_anchored_above_invoker

# Change the dialog's rendered size after it has been positioned. Without
# re-anchoring on size changes, the dialog keeps its original top and
# floats away from the trigger.
page.execute_script(<<~JS)
const dialog = document.querySelector('select-panel dialog')
dialog.style.minHeight = '0'
dialog.style.height = '150px'
JS

assert_anchored_above_invoker
end

def test_pressing_down_arrow_in_filter_input_focuses_first_item
visit_preview(:default)

Expand Down
Loading