Add ItemsRepeater focus restoration on Extensions settings page (part deux) (#45903)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

Fixes focus management in the Command Palette Extensions settings page.
After user moves through the extension list, when using Shift or
Shift+Tab to navigate into the extensions list, focus now properly
returns to the previously selected extension card instead of jumping to
the first item or end of the list.

This is part of the a11y bug batch.
User Impact:
Keyboard-only and assistive-technology users may lose context and
experience confusion due to unexpected focus movement, increasing
navigation effort and reducing usability of the Extensions page.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed



https://github.com/user-attachments/assets/2ebe25e4-015d-4804-8ae9-9a0107f39b8e

---------

Co-authored-by: Jiří Polášek <me@jiripolasek.com>
This commit is contained in:
Jessica Dene Earley-Cha
2026-03-24 09:00:05 -07:00
committed by GitHub
parent 5d0eabed15
commit cb9d54317a
2 changed files with 156 additions and 0 deletions

View File

@@ -216,6 +216,7 @@
<ItemsRepeater
x:Name="ProvidersRepeater"
x:Load="{x:Bind viewModel.Extensions.HasResults, Mode=OneWay}"
GettingFocus="ProvidersRepeater_GettingFocus"
ItemsSource="{x:Bind viewModel.Extensions.FilteredProviders, Mode=OneWay}"
Layout="{StaticResource VerticalStackLayout}">
<ItemsRepeater.ItemTemplate>
@@ -224,6 +225,7 @@
Click="SettingsCard_Click"
DataContext="{x:Bind}"
Description="{x:Bind ExtensionSubtext, Mode=OneWay}"
GotFocus="SettingsCard_GotFocus"
Header="{x:Bind DisplayName, Mode=OneWay}"
IsClickEnabled="True">
<controls:SettingsCard.HeaderIcon>

View File

@@ -19,6 +19,7 @@ public sealed partial class ExtensionsPage : Page
private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
private readonly SettingsViewModel? viewModel;
private int _lastFocusedIndex;
public ExtensionsPage()
{
@@ -58,4 +59,157 @@ public sealed partial class ExtensionsPage : Page
Logger.LogError("Error when showing FallbackRankerDialog", ex);
}
}
private void SettingsCard_GotFocus(object sender, RoutedEventArgs e)
{
// Track focus whenever any part of the card gets focus (including children like ToggleSwitch)
if (sender is SettingsCard card && viewModel is not null)
{
var dataContext = card.DataContext as ProviderSettingsViewModel;
if (dataContext is not null)
{
var filteredProviders = viewModel.Extensions.FilteredProviders;
var index = filteredProviders.IndexOf(dataContext);
if (index >= 0)
{
_lastFocusedIndex = index;
}
}
}
}
private void ProvidersRepeater_GettingFocus(UIElement sender, GettingFocusEventArgs args)
{
if (viewModel is null || ProvidersRepeater is null)
{
return;
}
// Only intervene when focus is coming into the ItemsRepeater from outside
if (args.OldFocusedElement != null && IsElementInsideRepeater(args.OldFocusedElement))
{
return;
}
var filteredProviders = viewModel.Extensions.FilteredProviders;
if (filteredProviders.Count == 0)
{
return;
}
// Get the last focused index, defaulting to 0
var index = _lastFocusedIndex;
if (index < 0 || index >= filteredProviders.Count)
{
index = 0;
}
// Check if WinUI is trying to focus something other than our target
var shouldIntervene = false;
// If direction is Previous (Shift+Tab), we need to intervene
if (args.Direction == FocusNavigationDirection.Previous || args.Direction == FocusNavigationDirection.Up)
{
shouldIntervene = true;
}
// Also intervene if the NewFocusedElement is not at our target index
else if (args.NewFocusedElement is DependencyObject newFocus)
{
// Check if the new focus element is inside our target card
var targetCard = ProvidersRepeater.TryGetElement(index) as SettingsCard;
if (targetCard != null && !IsElementInsideCard(newFocus, targetCard))
{
shouldIntervene = true;
}
}
if (shouldIntervene)
{
// Ensure the target element is realized before trying to focus it
ProvidersRepeater.GetOrCreateElement(index);
// Get the target card
var targetCard = ProvidersRepeater.TryGetElement(index) as SettingsCard;
if (targetCard != null)
{
// For shift-tab or wrong target, cancel and manually set focus
args.TryCancel();
args.Handled = true;
// Set focus asynchronously to the target card and scroll it into view
_ = targetCard.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () =>
{
targetCard.Focus(FocusState.Keyboard);
BringCardIntoView(targetCard);
});
}
}
else
{
// For normal Tab forward, just redirect
ProvidersRepeater.GetOrCreateElement(index);
var targetCard = ProvidersRepeater.TryGetElement(index) as SettingsCard;
if (targetCard != null)
{
args.TrySetNewFocusedElement(targetCard);
args.Handled = true;
// Set focus asynchronously to the target card and scroll it into view
_ = targetCard.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
{
BringCardIntoView(targetCard);
});
}
}
}
private void BringCardIntoView(SettingsCard card)
{
card.StartBringIntoView(new BringIntoViewOptions
{
AnimationDesired = true,
VerticalAlignmentRatio = 0.5, // Center vertically
});
}
private bool IsElementInsideCard(DependencyObject element, SettingsCard card)
{
var parent = element;
while (parent != null)
{
if (ReferenceEquals(parent, card))
{
return true;
}
parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(parent);
}
return false;
}
private bool IsElementInsideRepeater(object element)
{
if (element is not DependencyObject depObj)
{
return false;
}
var parent = depObj;
while (parent != null)
{
if (ReferenceEquals(parent, ProvidersRepeater))
{
return true;
}
parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(parent);
}
return false;
}
}