diff --git a/ILSpy.Tests/Views/FlagsFilterPopupKeyboardTests.cs b/ILSpy.Tests/Views/FlagsFilterPopupKeyboardTests.cs
new file mode 100644
index 000000000..029477933
--- /dev/null
+++ b/ILSpy.Tests/Views/FlagsFilterPopupKeyboardTests.cs
@@ -0,0 +1,123 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Linq;
+
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Headless;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ILSpy.Metadata.Filters;
+using ILSpy.Views.Filters;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Views.Filters;
+
+///
+/// Pins arrow-key navigation between chips inside .
+/// The popup is composed of one per axis plus an
+/// ; without explicit directional-navigation wiring
+/// the user can only Tab between every focusable element (chips + Clear button), which
+/// is awkward for a power-user filter. Arrow keys should hop chip-by-chip.
+///
+[TestFixture]
+public class FlagsFilterPopupKeyboardTests
+{
+ [System.Flags]
+ enum SampleAttrs : uint
+ {
+ Public = 1,
+ Private = 2,
+ VisibilityMask = 0x3,
+ Static = 0x10,
+ Abstract = 0x20,
+ }
+
+ [AvaloniaTest]
+ public void Right_Arrow_From_The_First_Chip_Moves_Focus_To_The_Next_Chip_In_The_Same_Group()
+ {
+ // Build the popup, push it onto a window so focus management works, focus the
+ // first chip, send Right. Default Avalonia DirectionalNavigation should carry
+ // focus through siblings inside the WrapPanel.
+ var schema = FlagsSchemaInferer.For(typeof(SampleAttrs));
+ var state = new FilterState(schema);
+ var popup = new FlagsFilterPopup(state);
+ var window = new Window { Content = popup };
+ window.Show();
+
+ var chips = popup.GetVisualDescendants().OfType().ToList();
+ chips.Should().HaveCountGreaterThan(1, "test relies on at least two chips being present");
+ var first = chips[0];
+ first.Focus();
+ first.IsFocused.Should().BeTrue("setup precondition — first chip must accept focus before we exercise arrow nav");
+
+ window.KeyPress(Key.Right, RawInputModifiers.None, PhysicalKey.ArrowRight, keySymbol: null);
+
+ var newlyFocused = chips.SingleOrDefault(c => c.IsFocused);
+ newlyFocused.Should().NotBeNull("Right arrow must transfer focus to a sibling chip");
+ ReferenceEquals(newlyFocused, first).Should().BeFalse(
+ "Right arrow must move focus AWAY from the first chip, not stay on it");
+ }
+
+ [AvaloniaTest]
+ public void Down_Arrow_From_A_Chip_In_The_First_Group_Lands_On_A_Chip_In_The_Next_Group()
+ {
+ // Vertical traversal: pressing Down from a chip in the first mutex group must move
+ // into the second group (or the independent-flags group if there's only one mutex
+ // group). Without arrow-traversal wiring the focus would stay inside the same
+ // WrapPanel because WrapPanel is a horizontal-flow container.
+ var schema = FlagsSchemaInferer.For(typeof(SampleAttrs));
+ var state = new FilterState(schema);
+ var popup = new FlagsFilterPopup(state);
+ var window = new Window { Content = popup };
+ window.Show();
+
+ var groups = popup.GetVisualDescendants()
+ .OfType()
+ .Where(c => c is MutexChipGroup || c is IndependentFlagGroup)
+ .ToList();
+ groups.Should().HaveCountGreaterThan(1,
+ "test relies on at least two filter groups (one mutex + one independent, or two mutex)");
+
+ // First group is a MutexChipGroup full of ToggleButton chips; second group is an
+ // IndependentFlagGroup full of Button pills. Allow either control type as a valid
+ // landing slot so we're testing the cross-group jump, not the chip type.
+ var firstGroupChips = groups[0].GetVisualDescendants().OfType().ToList();
+ var secondGroupTargets = groups[1].GetVisualDescendants()
+ .Where(d => d is ToggleButton || d is Button)
+ .ToList();
+ var anchor = firstGroupChips[0];
+ anchor.Focus();
+ anchor.IsFocused.Should().BeTrue();
+
+ window.KeyPress(Key.Down, RawInputModifiers.None, PhysicalKey.ArrowDown, keySymbol: null);
+
+ var focusedNow = popup.GetVisualDescendants()
+ .OfType()
+ .FirstOrDefault(c => c.IsFocused && (c is ToggleButton || c is Button));
+ focusedNow.Should().NotBeNull("Down arrow must keep focus on some chip in the popup");
+ secondGroupTargets.Contains(focusedNow!).Should().BeTrue(
+ "Down arrow from the first group must land on a chip/pill in the next group");
+ }
+}
diff --git a/ILSpy/Controls/FlagsFilterPopup.cs b/ILSpy/Controls/FlagsFilterPopup.cs
index f0abdcc34..00cf0b5c0 100644
--- a/ILSpy/Controls/FlagsFilterPopup.cs
+++ b/ILSpy/Controls/FlagsFilterPopup.cs
@@ -25,9 +25,12 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Styling;
+using Avalonia.VisualTree;
using ILSpy.Metadata.Filters;
@@ -104,10 +107,93 @@ namespace ILSpy.Views.Filters
Child = stack,
};
+ // Arrow-key nav between chips. Avalonia doesn't ship WPF's DirectionalNavigation
+ // attached property, so we handle Left/Right/Up/Down ourselves. Tunnel so we
+ // intercept before inner controls (the Clear button consumes Enter; arrow keys
+ // otherwise pass through to whatever has focus and do nothing).
+ AddHandler(KeyDownEvent, OnArrowKeyDown, RoutingStrategies.Tunnel);
+
state.PropertyChanged += OnStateChanged;
RefreshSummary();
}
+ void OnArrowKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key is not (Key.Left or Key.Right or Key.Up or Key.Down))
+ return;
+ // e.Source on a tunneled key event is the focused element about to receive it,
+ // regardless of whether FocusManager has stabilised in this headless tick.
+ // Fall back to FocusManager for completeness — both should usually agree.
+ var focused = (e.Source as Control)
+ ?? (TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Control);
+ if (focused is null)
+ return;
+
+ var groups = this.GetVisualDescendants()
+ .Where(c => c is MutexChipGroup or IndependentFlagGroup)
+ .OfType()
+ .ToList();
+ if (groups.Count == 0)
+ return;
+
+ Control? target = null;
+ if (e.Key is Key.Left or Key.Right)
+ {
+ // Flat document-order traversal: every chip / pill across the whole popup
+ // is one sequence. Left = previous, Right = next, wrap at ends so the user
+ // doesn't get stuck.
+ var flat = ChipsInDocumentOrder(groups).ToList();
+ int idx = flat.IndexOf(focused);
+ if (idx < 0)
+ return;
+ int step = e.Key == Key.Right ? 1 : -1;
+ target = flat[(idx + step + flat.Count) % flat.Count];
+ }
+ else
+ {
+ // Up / Down: jump to the previous / next group's first chip. Lets the user
+ // hop between axes without arrowing through every value chip in between.
+ int currentGroup = FindGroupContaining(groups, focused);
+ if (currentGroup < 0)
+ return;
+ int step = e.Key == Key.Down ? 1 : -1;
+ int newGroup = currentGroup + step;
+ if (newGroup < 0 || newGroup >= groups.Count)
+ return;
+ target = ChipsIn(groups[newGroup]).FirstOrDefault();
+ }
+
+ if (target is not null)
+ {
+ target.Focus();
+ e.Handled = true;
+ }
+ }
+
+ static int FindGroupContaining(IReadOnlyList groups, Control element)
+ {
+ for (int i = 0; i < groups.Count; i++)
+ if (groups[i] == element || groups[i].GetVisualDescendants().Contains(element))
+ return i;
+ return -1;
+ }
+
+ // Each group exposes its own NavigableElements list — using that instead of a
+ // blanket descendant-walk skips past internal template parts (e.g. ComboBox's
+ // own toggle button) so arrow nav lands only on user-facing chips/pills.
+ static IEnumerable ChipsIn(Control group) => group switch {
+ MutexChipGroup mutex => mutex.NavigableElements,
+ IndependentFlagGroup indep => indep.NavigableElements,
+ _ => Enumerable.Empty(),
+ };
+
+ static IEnumerable ChipsInDocumentOrder(IEnumerable groups)
+ {
+ foreach (var group in groups)
+ foreach (var chip in ChipsIn(group))
+ yield return chip;
+ }
+
void OnStateChanged(object? sender, PropertyChangedEventArgs e) => RefreshSummary();
void RefreshSummary() => summary.Text = FilterStatePresenter.Describe(state);
diff --git a/ILSpy/Controls/IndependentFlagGroup.cs b/ILSpy/Controls/IndependentFlagGroup.cs
index bcdee711f..dc6412a99 100644
--- a/ILSpy/Controls/IndependentFlagGroup.cs
+++ b/ILSpy/Controls/IndependentFlagGroup.cs
@@ -41,6 +41,12 @@ namespace ILSpy.Views.Filters
readonly Dictionary pills = new();
readonly ComboBox modeBox;
+ ///
+ /// Every user-facing pill in document order. Used by the popup's arrow-key
+ /// navigation to skip past internal template parts of the All/Any ComboBox.
+ ///
+ public IEnumerable NavigableElements => pills.Values;
+
public IndependentFlagGroup(FilterState state, IReadOnlyList flags)
{
this.state = state ?? throw new ArgumentNullException(nameof(state));
diff --git a/ILSpy/Controls/MutexChipGroup.cs b/ILSpy/Controls/MutexChipGroup.cs
index 457aaa31f..ab8ae4d16 100644
--- a/ILSpy/Controls/MutexChipGroup.cs
+++ b/ILSpy/Controls/MutexChipGroup.cs
@@ -48,6 +48,20 @@ namespace ILSpy.Views.Filters
readonly Dictionary valueChips = new();
bool suppress;
+ ///
+ /// Every user-facing chip in document order — the Any-chip first, then each
+ /// value chip. Used by the popup's arrow-key navigation to skip past internal
+ /// template parts (ComboBox toggle buttons etc.) that descendant-walking would
+ /// otherwise pick up.
+ ///
+ public IEnumerable NavigableElements {
+ get {
+ yield return anyChip;
+ foreach (var (_, chip) in valueChips)
+ yield return chip;
+ }
+ }
+
public MutexChipGroup(FilterState state, MutexGroup group)
{
this.state = state ?? throw new ArgumentNullException(nameof(state));