Skip to content

Commit ca58e38

Browse files
Host the POS-chooser and option-picker dropdowns in flyouts, not free popups
Both collapsed dropdowns previously opened a free Popup, which renders in its own PopupRoot top-level. Free popups misplace themselves under fractional display scaling (the popup-DPI quirk); a Flyout instead positions in the trigger's own surface, so it stays correctly placed. This matches the dialog-ownership parity contract for on-top dropdowns. FwOptionPicker: its dropdown mode now opens through the same flyout path its inline mode already uses. The Popup+manual placement was replaced with the chromeless, focus-posting Flyout built by CreateOptionFlyout (refactored to a shared BuildOptionFlyout so inline content and the dropdown's filter+list panel both open through one implementation), anchored to the toggle button via ShowAt and dismissed via Hide. FwPosChooser: the collapsed POS tree dropdown was likewise moved from a free Popup to a Flyout anchored to the toggle button, with the Fluent presenter chrome stripped so the panel's own thin border is the only boundary. Behavior is preserved in both: the dropdown opens on the same trigger, shows the same content (the POS tree / the option list), keeps Up/Down/Enter/Escape keyboard navigation, commits the selection the same way, and dismisses on pick or light-dismiss. Open state now tracks the flyout's Opened/Closed events. Added a regression guard to each control's tests asserting the control's own logical tree contains no Popup element while the dropdown is open (the flyout overlay is not a logical child), so the free-popup pattern cannot silently return. Existing open/type/select/close behavioral tests stay green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1f70427 commit ca58e38

4 files changed

Lines changed: 170 additions & 89 deletions

File tree

Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using Avalonia.Headless.NUnit;
1313
using Avalonia.Input;
1414
using Avalonia.Interactivity;
15+
using Avalonia.LogicalTree;
1516
using Avalonia.Threading;
1617
using Avalonia.VisualTree;
1718
using NUnit.Framework;
@@ -596,6 +597,33 @@ public void Dropdown_CollapsedByDefault_ShowsSelection_PopupClosed_NoFilterFocus
596597
Assert.That(toggle, Is.Not.Null, "the collapsed dropdown exposes a stable toggle automation id");
597598
}
598599

600+
[AvaloniaTest]
601+
public void Dropdown_IsHostedInAFlyout_NotAFreeStandingPopup()
602+
{
603+
// Parity/guidance guard: dropdown mode must open its filter+list on a Flyout anchored to the
604+
// toggle button (the same CreateOptionFlyout path the inline consumers use), never a free
605+
// Popup living in the picker's own tree. A free popup opens its own top-level and misplaces
606+
// itself under fractional display scaling; a flyout positions itself in the trigger's surface.
607+
// Assert the picker owns NO Popup element, collapsed AND while the dropdown is open.
608+
var (picker, window, _) = ShowDropdown();
609+
window.UpdateLayout();
610+
Dispatcher.UIThread.RunJobs();
611+
Assert.That(picker.GetLogicalDescendants().OfType<Popup>(), Is.Empty,
612+
"the collapsed dropdown hosts no free Popup");
613+
614+
picker.OpenDropdown();
615+
Dispatcher.UIThread.RunJobs();
616+
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
617+
Dispatcher.UIThread.RunJobs();
618+
619+
Assert.That(picker.IsDropdownOpen, Is.True, "the dropdown opens");
620+
Assert.That(picker.GetLogicalDescendants().OfType<Popup>(), Is.Empty,
621+
"opening adds no free Popup to the picker tree — the list rides a Flyout top-level");
622+
Assert.That(picker.OptionsList.GetVisualDescendants().OfType<TextBlock>()
623+
.Any(t => t.Text == "Universe"), Is.True,
624+
"the option list is realized in the flyout overlay");
625+
}
626+
599627
[AvaloniaTest]
600628
public void Dropdown_Open_ShowsTheOptionListOnTop_FilterFocused()
601629
{

Src/Common/FwAvalonia/FwAvaloniaTests/FwPosChooserTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using Avalonia.Headless.NUnit;
1313
using Avalonia.Input;
1414
using Avalonia.Interactivity;
15+
using Avalonia.LogicalTree;
1516
using Avalonia.Threading;
1617
using Avalonia.VisualTree;
1718
using NUnit.Framework;
@@ -169,6 +170,32 @@ public void Click_OpensTreePopup_OnTop_WithHierarchyExpanded()
169170
CaptureOpenPopupContent("FwPosChooser-02-open-tree", filtered: false);
170171
}
171172

173+
[AvaloniaTest]
174+
public void Dropdown_IsHostedInAFlyout_NotAFreeStandingPopup()
175+
{
176+
// Parity/guidance guard: the tree dropdown must ride on a Flyout anchored to the toggle
177+
// button, never a free Popup living in the chooser's own tree. A free popup opens its own
178+
// top-level and misplaces itself under fractional display scaling; a flyout positions itself
179+
// in the trigger's surface. Assert the chooser owns NO Popup element, collapsed AND while the
180+
// dropdown is open (the flyout's overlay is not a logical child of the chooser).
181+
var (chooser, window, _, _) = Show();
182+
window.UpdateLayout();
183+
Dispatcher.UIThread.RunJobs();
184+
Assert.That(chooser.GetLogicalDescendants().OfType<Popup>(), Is.Empty,
185+
"the collapsed chooser hosts no free Popup");
186+
187+
chooser.Open();
188+
Dispatcher.UIThread.RunJobs();
189+
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
190+
Dispatcher.UIThread.RunJobs();
191+
192+
Assert.That(chooser.IsOpen, Is.True, "the dropdown opens");
193+
Assert.That(chooser.GetLogicalDescendants().OfType<Popup>(), Is.Empty,
194+
"opening adds no free Popup to the chooser tree — the tree rides a Flyout top-level");
195+
Assert.That(chooser.Tree.GetVisualDescendants().OfType<TreeViewItem>().Any(), Is.True,
196+
"the tree content is realized in the flyout overlay");
197+
}
198+
172199
[AvaloniaTest]
173200
public void PickingTreeNode_Commits_Collapses_UpdatesId_AndRaisesSelectionChanged()
174201
{

Src/Common/FwAvalonia/FwPosChooser.cs

Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,13 @@ public sealed class FwPosChooser : Border
7676
private readonly bool _allowEmpty;
7777
private string _emptyLabelText;
7878

79-
// Collapsed presentation (the field-sized box the user sees when the popup is closed).
79+
// Collapsed presentation (the field-sized box the user sees when the dropdown is closed).
8080
private readonly ToggleButton _dropdownButton;
8181
private readonly TextBlock _dropdownLabel;
82-
private readonly Popup _popup;
82+
private readonly Flyout _flyout;
83+
private bool _open;
8384

84-
// Popup content: a filter box stacked over EITHER the tree (no filter) or a flat result list (filter).
85+
// Dropdown content: a filter box stacked over EITHER the tree (no filter) or a flat result list (filter).
8586
private readonly TextBox _filterBox;
8687
private readonly TreeView _tree;
8788
private readonly ListBox _filterList;
@@ -238,32 +239,29 @@ public FwPosChooser(string automationId, bool allowEmpty = true, string emptyLab
238239
MinWidth = FwAvaloniaDensity.DropdownMinWidth + 20,
239240
Child = body
240241
};
241-
// The popup renders in its OWN top-level (PopupRoot), so keys typed in the filter box do not
242-
// bubble to the chooser root. Register the navigation handler on the popup panel so
243-
// Up/Down/Enter/Escape work while it is open (same pattern as FwOptionPicker dropdown mode).
242+
AutomationProperties.SetAutomationId(_popupPanel, _automationId + ".Popup");
243+
// The flyout body renders in its OWN top-level, so keys typed in the filter box do not bubble
244+
// to the chooser root. Register the navigation handler on the panel so Up/Down/Enter/Escape
245+
// work while it is open (same pattern as FwOptionPicker dropdown mode).
244246
_popupPanel.AddHandler(InputElement.KeyDownEvent, OnPopupKeyDown,
245247
RoutingStrategies.Bubble, handledEventsToo: true);
246248

247-
_popup = new Popup
249+
// Host the tree on a Flyout anchored to the toggle button, not a free popup: a flyout
250+
// positions itself in the trigger's own surface, so the dropdown stays correctly placed under
251+
// fractional display scaling. The Fluent presenter chrome is stripped so the panel's own thin
252+
// border is the only boundary the user sees.
253+
_flyout = new Flyout
248254
{
249-
PlacementTarget = _dropdownButton,
250255
Placement = PlacementMode.Bottom,
251-
IsLightDismissEnabled = true,
252-
// Zero footprint when closed so the layout tripwire never sees it overlapping the button.
253-
Width = 0,
254-
Height = 0,
255-
HorizontalAlignment = HorizontalAlignment.Left,
256-
VerticalAlignment = VerticalAlignment.Top,
257-
Child = _popupPanel
256+
Content = _popupPanel,
257+
FlyoutPresenterTheme = ChromelessPresenterTheme()
258258
};
259-
AutomationProperties.SetAutomationId(_popup, _automationId + ".Popup");
260-
_popup.Opened += OnPopupOpened;
261-
_popup.Closed += OnPopupClosed;
259+
_flyout.Opened += OnFlyoutOpened;
260+
_flyout.Closed += OnFlyoutClosed;
262261

263-
var root = new Panel();
264-
root.Children.Add(_dropdownButton);
265-
root.Children.Add(_popup);
266-
Child = root;
262+
// The chooser's own visual content is just the collapsed toggle button; the tree lives in the
263+
// flyout's separate top-level, shown on demand.
264+
Child = _dropdownButton;
267265

268266
UpdateCollapsedLabel();
269267
}
@@ -303,8 +301,8 @@ public string SelectedPosId
303301
/// <summary>The display name shown collapsed (the selected POS name, or the empty-row label).</summary>
304302
public string SelectedDisplayText => _dropdownLabel.Text ?? string.Empty;
305303

306-
/// <summary>True when the tree popup is currently open.</summary>
307-
public bool IsOpen => _popup.IsOpen;
304+
/// <summary>True when the tree flyout is currently open.</summary>
305+
public bool IsOpen => _open;
308306

309307
/// <summary>The collapsed toggle button (the field-sized box the user clicks). For tests/hosts.</summary>
310308
public ToggleButton DropdownButton => _dropdownButton;
@@ -319,16 +317,17 @@ public string SelectedPosId
319317
public ListBox FilteredList => _filterList;
320318

321319
/// <summary>
322-
/// Detaches and returns the on-top popup CONTENT panel (filter + tree/filter-list + create row)
323-
/// for headless PNG capture only: a <see cref="Popup"/> renders in its own top-level, which the
324-
/// host window's CaptureRenderedFrame does not include, so a snapshot of the chooser shows only the
325-
/// collapsed box. Hosting THIS panel in a capture window renders the actual on-top tree the user
326-
/// sees. The chooser is left non-functional afterward (the popup is emptied), so call it on a
327-
/// throwaway instance — never in production.
320+
/// Detaches and returns the on-top flyout CONTENT panel (filter + tree/filter-list + create row)
321+
/// for headless PNG capture only: a flyout renders in its own top-level, which the host window's
322+
/// CaptureRenderedFrame does not include, so a snapshot of the chooser shows only the collapsed
323+
/// box. Hosting THIS panel in a capture window renders the actual on-top tree the user sees. The
324+
/// chooser is left non-functional afterward (the flyout is emptied), so call it on a throwaway
325+
/// instance — never in production.
328326
/// </summary>
329327
public Control DetachPopupContentForCapture()
330328
{
331-
_popup.Child = null;
329+
_flyout.Hide();
330+
_flyout.Content = null;
332331
return _popupPanel;
333332
}
334333

@@ -478,13 +477,19 @@ private void CommitSelection(string id, string display, bool raise)
478477

479478
private void OnDropdownButtonCheckedChanged(object sender, RoutedEventArgs e)
480479
{
481-
_popup.IsOpen = _dropdownButton.IsChecked == true;
480+
if (_flyout == null)
481+
return;
482+
if (_dropdownButton.IsChecked == true)
483+
_flyout.ShowAt(_dropdownButton);
484+
else
485+
_flyout.Hide();
482486
}
483487

484-
private void OnPopupOpened(object sender, EventArgs e)
488+
private void OnFlyoutOpened(object sender, EventArgs e)
485489
{
486490
// Start each open with a clean filter (tree shown), then focus the filter box so typing filters
487-
// immediately. Posted at Input priority so it runs after the popup's own layout/template.
491+
// immediately. Posted at Input priority so it runs after the flyout's own layout/template.
492+
_open = true;
488493
if (!string.IsNullOrEmpty(_filterBox.Text))
489494
_filterBox.Text = string.Empty;
490495
else
@@ -493,15 +498,17 @@ private void OnPopupOpened(object sender, EventArgs e)
493498
Avalonia.Threading.DispatcherPriority.Input);
494499
}
495500

496-
private void OnPopupClosed(object sender, EventArgs e)
501+
private void OnFlyoutClosed(object sender, EventArgs e)
497502
{
503+
// Keep the toggle button in sync when the flyout closes by light-dismiss (click-away).
504+
_open = false;
498505
if (_dropdownButton.IsChecked == true)
499506
_dropdownButton.IsChecked = false;
500507
}
501508

502509
private void CloseDropdown()
503510
{
504-
_popup.IsOpen = false;
511+
_flyout.Hide();
505512
_dropdownButton.IsChecked = false;
506513
}
507514

@@ -698,6 +705,23 @@ private static ControlTheme CompactListItemTheme()
698705
return theme;
699706
}
700707

708+
// Strip the Fluent FlyoutPresenter's heavy grey padding/border/background to nothing, so the
709+
// tree panel's own thin border is the only boundary the user sees (mirrors the option-picker
710+
// flyout look, keeping the two dropdowns visually consistent).
711+
private static ControlTheme ChromelessPresenterTheme()
712+
{
713+
ControlTheme baseTheme = null;
714+
if (Application.Current != null
715+
&& Application.Current.TryGetResource(typeof(FlyoutPresenter), null, out var found))
716+
baseTheme = found as ControlTheme;
717+
var theme = new ControlTheme(typeof(FlyoutPresenter)) { BasedOn = baseTheme };
718+
theme.Setters.Add(new Setter(TemplatedControl.PaddingProperty, new Thickness(0)));
719+
theme.Setters.Add(new Setter(TemplatedControl.BorderThicknessProperty, new Thickness(0)));
720+
theme.Setters.Add(new Setter(TemplatedControl.BackgroundProperty, Brushes.Transparent));
721+
theme.Setters.Add(new Setter(TemplatedControl.CornerRadiusProperty, new CornerRadius(0)));
722+
return theme;
723+
}
724+
701725
/// <summary>
702726
/// A tree node bound by the chooser's TreeView. Carries the expansion state (two-way bound to the
703727
/// container) and the children, mirroring ChooserTreeNode but POS-specific and LCModel-free.

0 commit comments

Comments
 (0)