mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 09:46:54 +02:00
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:
committed by
GitHub
parent
5d0eabed15
commit
cb9d54317a
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user