Files
PowerToys/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs
Jessica Dene Earley-Cha 6242401b40 add AutomationNotification for screen readers (#40761)
## Summary of the Pull Request

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

- [ x ] **Closes:** #38392
- [ 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


## Detailed Description of the Pull Request / Additional comments

Add AutomationNotification to ItemsList_SelectionChanged so that when
user uses keyboard navigation, it sends the title to be read by the
screen reader


## Validation Steps Performed


https://github.com/user-attachments/assets/34a11e55-18ce-440f-97d8-e6ea60c57f78

Co-authored-by: Mike Griese <migrie@microsoft.com>
2025-07-28 09:56:01 -05:00

345 lines
12 KiB
C#

// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
namespace Microsoft.CmdPal.UI;
public sealed partial class ListPage : Page,
IRecipient<NavigateNextCommand>,
IRecipient<NavigatePreviousCommand>,
IRecipient<ActivateSelectedListItemMessage>,
IRecipient<ActivateSecondaryCommandMessage>
{
private ListViewModel? ViewModel
{
get => (ListViewModel?)GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}
// Using a DependencyProperty as the backing store for ViewModel. This enables animation, styling, binding, etc...
public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(nameof(ViewModel), typeof(ListViewModel), typeof(ListPage), new PropertyMetadata(null, OnViewModelChanged));
public ListPage()
{
this.InitializeComponent();
this.NavigationCacheMode = NavigationCacheMode.Disabled;
this.ItemsList.Loaded += ItemsList_Loaded;
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
if (e.Parameter is ListViewModel lvm)
{
ViewModel = lvm;
}
if (e.NavigationMode == NavigationMode.Back
|| (e.NavigationMode == NavigationMode.New && ItemsList.Items.Count > 0))
{
// Upon navigating _back_ to this page, immediately select the
// first item in the list
ItemsList.SelectedIndex = 0;
}
// RegisterAll isn't AOT compatible
WeakReferenceMessenger.Default.Register<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this);
WeakReferenceMessenger.Default.Register<ActivateSecondaryCommandMessage>(this);
base.OnNavigatedTo(e);
}
protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
{
base.OnNavigatingFrom(e);
WeakReferenceMessenger.Default.Unregister<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Unregister<ActivateSelectedListItemMessage>(this);
WeakReferenceMessenger.Default.Unregister<ActivateSecondaryCommandMessage>(this);
if (ViewModel != null)
{
ViewModel.PropertyChanged -= ViewModel_PropertyChanged;
ViewModel.ItemsUpdated -= Page_ItemsUpdated;
}
if (e.NavigationMode != NavigationMode.New)
{
ViewModel?.SafeCleanup();
CleanupHelper.Cleanup(this);
Bindings.StopTracking();
}
// Clean-up event listeners
ViewModel = null;
GC.Collect();
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")]
private void ItemsList_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is ListItemViewModel item)
{
var settings = App.Current.Services.GetService<SettingsModel>()!;
if (settings.SingleClickActivates)
{
ViewModel?.InvokeItemCommand.Execute(item);
}
else
{
ViewModel?.UpdateSelectedItemCommand.Execute(item);
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
}
}
}
private void ItemsList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
{
if (ItemsList.SelectedItem is ListItemViewModel vm)
{
var settings = App.Current.Services.GetService<SettingsModel>()!;
if (!settings.SingleClickActivates)
{
ViewModel?.InvokeItemCommand.Execute(vm);
}
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")]
private void ItemsList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
var vm = ViewModel;
var li = ItemsList.SelectedItem as ListItemViewModel;
_ = Task.Run(() =>
{
vm?.UpdateSelectedItemCommand.Execute(li);
});
// There's mysterious behavior here, where the selection seemingly
// changes to _nothing_ when we're backspacing to a single character.
// And at that point, seemingly the item that's getting removed is not
// a member of FilteredItems. Very bizarre.
//
// Might be able to fix in the future by stashing the removed item
// here, then in Page_ItemsUpdated trying to select that cached item if
// it's in the list (otherwise, clear the cache), but that seems
// aggressively BODGY for something that mostly just works today.
if (ItemsList.SelectedItem != null)
{
ItemsList.ScrollIntoView(ItemsList.SelectedItem);
// Automation notification for screen readers
var listViewPeer = Microsoft.UI.Xaml.Automation.Peers.ListViewAutomationPeer.CreatePeerForElement(ItemsList);
if (listViewPeer != null && li != null)
{
var notificationText = li.Title;
listViewPeer.RaiseNotificationEvent(
Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationKind.Other,
Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationProcessing.MostRecent,
notificationText,
"CommandPaletteSelectedItemChanged");
}
}
}
private void ItemsList_Loaded(object sender, RoutedEventArgs e)
{
// Find the ScrollViewer in the ListView
var listViewScrollViewer = FindScrollViewer(this.ItemsList);
if (listViewScrollViewer != null)
{
listViewScrollViewer.ViewChanged += ListViewScrollViewer_ViewChanged;
}
}
private void ListViewScrollViewer_ViewChanged(object? sender, ScrollViewerViewChangedEventArgs e)
{
var scrollView = sender as ScrollViewer;
if (scrollView == null)
{
return;
}
// When we get to the bottom, request more from the extension, if they
// have more to give us.
// We're checking when we get to 80% of the scroll height, to give the
// extension a bit of a heads-up before the user actually gets there.
if (scrollView.VerticalOffset >= (scrollView.ScrollableHeight * .8))
{
ViewModel?.LoadMoreIfNeeded();
}
}
public void Receive(NavigateNextCommand message)
{
// Note: We may want to just have the notion of a 'SelectedCommand' in our VM
// And then have these commands manipulate that state being bound to the UI instead
// We may want to see how other non-list UIs need to behave to make this decision
// At least it's decoupled from the SearchBox now :)
if (ItemsList.SelectedIndex < ItemsList.Items.Count - 1)
{
ItemsList.SelectedIndex++;
}
else
{
ItemsList.SelectedIndex = 0;
}
}
public void Receive(NavigatePreviousCommand message)
{
if (ItemsList.SelectedIndex > 0)
{
ItemsList.SelectedIndex--;
}
else
{
ItemsList.SelectedIndex = ItemsList.Items.Count - 1;
}
}
public void Receive(ActivateSelectedListItemMessage message)
{
if (ViewModel?.ShowEmptyContent ?? false)
{
ViewModel?.InvokeItemCommand.Execute(null);
}
else if (ItemsList.SelectedItem is ListItemViewModel item)
{
ViewModel?.InvokeItemCommand.Execute(item);
}
}
public void Receive(ActivateSecondaryCommandMessage message)
{
if (ViewModel?.ShowEmptyContent ?? false)
{
ViewModel?.InvokeSecondaryCommandCommand.Execute(null);
}
else if (ItemsList.SelectedItem is ListItemViewModel item)
{
ViewModel?.InvokeSecondaryCommandCommand.Execute(item);
}
}
private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ListPage @this)
{
if (e.OldValue is ListViewModel old)
{
old.PropertyChanged -= @this.ViewModel_PropertyChanged;
old.ItemsUpdated -= @this.Page_ItemsUpdated;
}
if (e.NewValue is ListViewModel page)
{
page.PropertyChanged += @this.ViewModel_PropertyChanged;
page.ItemsUpdated += @this.Page_ItemsUpdated;
}
else if (e.NewValue == null)
{
Logger.LogDebug("cleared view model");
}
}
}
// Called after we've finished updating the whole list for either a
// GetItems or a change in the filter.
private void Page_ItemsUpdated(ListViewModel sender, object args)
{
// If for some reason, we don't have a selected item, fix that.
//
// It's important to do this here, because once there's no selection
// (which can happen as the list updates) we won't get an
// ItemsList_SelectionChanged again to give us another chance to change
// the selection from null -> something. Better to just update the
// selection once, at the end of all the updating.
if (ItemsList.SelectedItem == null)
{
ItemsList.SelectedIndex = 0;
}
// Always reset the selected item when the top-level list page changes
// its items
if (!sender.IsNested)
{
ItemsList.SelectedIndex = 0;
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
var prop = e.PropertyName;
if (prop == nameof(ViewModel.FilteredItems))
{
Debug.WriteLine($"ViewModel.FilteredItems {ItemsList.SelectedItem}");
}
}
private ScrollViewer? FindScrollViewer(DependencyObject parent)
{
if (parent is ScrollViewer)
{
return (ScrollViewer)parent;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
var result = FindScrollViewer(child);
if (result != null)
{
return result;
}
}
return null;
}
private void ItemsList_RightTapped(object sender, RightTappedRoutedEventArgs e)
{
if (e.OriginalSource is FrameworkElement element &&
element.DataContext is ListItemViewModel item)
{
if (ItemsList.SelectedItem != item)
{
ItemsList.SelectedItem = item;
}
ViewModel?.UpdateSelectedItemCommand.Execute(item);
var pos = e.GetPosition(element);
_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
}
}
}