Dave R.
Dave R.

Reputation: 7294

WinUI 3 - How to set focus to a ContentDialog button

I have a WinUI 3 application written in C# which shows a ContentDialog to ask the user to confirm file deletions. There is a checkbox on the dialog which can be checked by the user to hide the dialog from being displayed in the future:

enter image description here

Although this works, I have a usability issue. The default behaviour for when a ContentDialog is opened is to set focus on the first interactive element, which is the checkbox. I don't want this. I want focus to be on the default button, which is the CloseButton. This is the safest non-destructive UI element.

Update: this is actually related to the inconsistent display of the focus highlight - the rounded rectangle around the current control. Opening the ContentDialog via a Button click does not show the focus rectangle, whereas a keyboard event like KeyUp or handling a KeyboardAccelerator results in the undesired behaviour of showing it. This looks like a WinUI 3 bug.

I'm aware of VisualTreeHelper.GetOpenPopupsForXamlRoot() and I've experimented with that in the dialog's Opened event handler, but navigating the subsequent hierarchy is not straightforward (FindName("CloseButton") does not work, for example), and I can't help thinking there's either a more direct way of accessing the button, or someone has written a helper to do the same.

I have also tried adding a GettingFocus event handler for the checkbox and doing args.TryCancel() if the dialog has just opened, but this actually ended up kicking the focus to the parent window, which is definitely not what I want!

To recreate this issue:

  1. Create a new project in Visual Studio. Choose "Blank App, Packaged (WinUI 3 in Desktop)" as the project template.

  2. Name the project "CheckboxTest".

  3. Replace the MainWindow.xaml with the XAML below:

<Window
    x:Class="CheckboxTest.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:CheckboxTest"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Title="ContentDialog Focus Test">

    <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
        <ContentDialog
            x:Name="DeleteConfirmationDialog"
            Title="Delete file"
            PrimaryButtonText="Move to Recycle Bin"
            CloseButtonText="Cancel"
            DefaultButton="Close">
            <StackPanel VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Spacing="12">
                <TextBlock TextWrapping="Wrap" Text="Are you sure you want to move file 'somefile.jpg' to the Recycle Bin?" />
                <CheckBox x:Name="DeleteDontAskCheckbox" Content="Don't ask me again" />
            </StackPanel>
        </ContentDialog>
    </StackPanel>
</Window>
  1. Replace the MainWindow.xaml.cs code with:
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;

namespace CheckboxTest;

public sealed partial class MainWindow : Window
{
    public MainWindow()
    {
        this.InitializeComponent();
        this.Content.KeyUp += Content_KeyUp;
    }

    private async void Content_KeyUp(object sender, KeyRoutedEventArgs e)
    {
        if (e.Key == Windows.System.VirtualKey.Delete)
        {
            DeleteConfirmationDialog.XamlRoot = Content.XamlRoot;
            if (await DeleteConfirmationDialog.ShowAsync() == ContentDialogResult.Primary)
            {
                // Do delete operation...
            }
        }
    }
}
  1. Run the project.

  2. Press Delete and observe the confirmation dialog displayed has the checkbox focussed.

Upvotes: 1

Views: 124

Answers (3)

Dave R.
Dave R.

Reputation: 7294

The underlying issue here is the inconsistency regarding whether the focus visual is displayed, not which control should receive focus. The checkbox should always be focussed when the dialog is opened, as it is the first user-interactive control. We should actually not be messing with the focus programmatically, as this will lead to behaviour inconsistent with a standard ContentDialog.

My solution is to set the focus visual thickness to (0,0,0,0) when the dialog is displayed, and then restore the previous thickness when the user begins to interact with the dialog via the keyboard. This means that all controls are still accessible via screen readers etc., the user tabbing from one control to another is consistent, and the controls are in the correct tab order.

To get the thickness values, I add a hidden control dynamically, retrieve its properties, and then remove it again. This approach isn't reliant on any existing controls being available on the calling page to query, and is also responsive to theme changes and accessibility modes like High Contrast.

This all means that opening the dialog via a button click or by a keyboard action results in consistent focus behaviour, bypassing the WinUI issue.

The XAML is broadly the same as in my original question, but has an added Name for the main StackPanel. This lets us add and remove the hidden control at runtime to get access to the focus thickness values:

<Window
    x:Class="CheckboxTest.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:CheckboxTest"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Title="ContentDialog Focus Test">

    <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center" Name="MainStackPanel">
        <Button Name="ShowDialogButton" Content="Show Dialog" Click="ShowDialogButton_Click" />
        <ContentDialog
            x:Name="DeleteConfirmationDialog"
            Title="Delete file"
            PrimaryButtonText="Move to Recycle Bin"
            CloseButtonText="Cancel"
            DefaultButton="Close">
            <StackPanel VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Spacing="12">
                <TextBlock TextWrapping="Wrap" Text="Are you sure you want to move file 'somefile.jpg' to the Recycle Bin?" />
                <CheckBox x:Name="DeleteDontAskCheckbox" Content="Don't ask me again" />
            </StackPanel>
        </ContentDialog>
    </StackPanel>
</Window>

The MainWindow.xaml.cs code is:

using System;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;

namespace CheckboxTest;

public sealed partial class MainWindow : Window
{
    private readonly Thickness _zeroThickness = new(0);
    private Thickness _defaultFocusPrimaryThickness = new(2);
    private Thickness _defaultFocusSecondaryThickness = new(2);

    public MainWindow()
    {
        this.InitializeComponent();
        this.Content.KeyUp += Content_KeyUp;
    }

    private async void Content_KeyUp(object sender, KeyRoutedEventArgs e)
    {
        if (e.Key == Windows.System.VirtualKey.Delete)
        {
            if (await OpenDeleteDialog() == ContentDialogResult.Primary)
            {
                // ... do delete stuff
            }
        }

        // Restore the default focus visual. The Space key is excluded as that is used to toggle
        // the checkbox when it has focus.
        if (e.Key != Windows.System.VirtualKey.Space)
        {
            DeleteDontAskCheckbox.FocusVisualPrimaryThickness = _defaultFocusPrimaryThickness;
            DeleteDontAskCheckbox.FocusVisualSecondaryThickness = _defaultFocusSecondaryThickness;
        }
    }

    private async void ShowDialogButton_Click(object sender, RoutedEventArgs e)
    {
        if (await OpenDeleteDialog() == ContentDialogResult.Primary)
        {
            // ... do delete stuff
        }
    }

    private async Task<ContentDialogResult> OpenDeleteDialog()
    {
        // Save the current focus visual thickness. This will be restored when the user interacts
        // with the dialog, e.g. by using Tab.
        var hiddenCheckBox = new CheckBox { Visibility = Visibility.Collapsed };
        MainStackPanel.Children.Add(hiddenCheckBox);
        _defaultFocusPrimaryThickness = hiddenCheckBox.FocusVisualPrimaryThickness;
        _defaultFocusSecondaryThickness = hiddenCheckBox.FocusVisualSecondaryThickness;
        MainStackPanel.Children.Remove(hiddenCheckBox);

        // Hide the default focus visual. This prevents its initial display when the dialog is
        // opened via a keyboard event.
        DeleteDontAskCheckbox.FocusVisualPrimaryThickness = _zeroThickness;
        DeleteDontAskCheckbox.FocusVisualSecondaryThickness = _zeroThickness;

        // Open the dialog and return its result.
        DeleteConfirmationDialog.XamlRoot = Content.XamlRoot;
        return await DeleteConfirmationDialog.ShowAsync();
    }
}

Upvotes: 0

IV.
IV.

Reputation: 8736

I see there is an answer that provides a simple way to keep DeleteDontAskCheckbox from taking the focus. In terms of the other requirement of accessing the buttons and setting focus to [Cancel], enumerating all of the controls might work where FindName won't.

private IEnumerable<DependencyObject> Traverse(DependencyObject parent)
{
    if (parent == null)
        yield break;

    yield return parent; 
    if (parent is Popup popup && popup.Child is DependencyObject popupContent)
    {
        foreach (var descendant in Traverse(popupContent))
        {
            yield return descendant;
        }
    }
    else
    {
        int childCount = VisualTreeHelper.GetChildrenCount(parent);
        for (int i = 0; i < childCount; i++)
        {
            var child = VisualTreeHelper.GetChild(parent, i);
            foreach (var descendant in Traverse(child))
            {
                yield return descendant;
            }
        }
    }
}

You said:

I'm aware of VisualTreeHelper.GetOpenPopupsForXamlRoot() and I've experimented with that in the dialog's Opened event handler, but navigating the subsequent hierarchy is not straightforward (FindName("CloseButton") does not work, for example), and I can't help thinking there's either a more direct way of accessing the button, or someone has written a helper to do the same.

The usage of the enumerator, the (somewhat) "more direct way", would look something like this:

var buttonCancel = Traverse(dialog)
    .OfType<Button>()
    .FirstOrDefault(_ => _.Content?.ToString() == "Cancel");

Using, as you mentioned, the dialog's Opened event handler, I tested this as a proof of concept only. You may still have to play around with it a bit. I'll put a link to the code I used to test it if you want to make sure it works on your end.

private void OnContentDialogOpened(ContentDialog sender, ContentDialogOpenedEventArgs args)
{
    if (sender is ContentDialog dialog)
    {
        if( !dialog.DispatcherQueue.TryEnqueue(() => 
        {
            const string BUTTON_TO_FOCUS = "Cancel";

            // Find by text
            if (Traverse(dialog)
                .OfType<Button>()
                .FirstOrDefault(_ => _.Content?.ToString() == BUTTON_TO_FOCUS) is { } button)
            {
                if (button.FocusState == FocusState.Unfocused)
                {
                    button.Focus(FocusState.Programmatic);
                }
            }
        }))
        {
            Debug.WriteLine("Failed to enqueue action.");
        }
    }
}

cancel button focused


XAML with Opened event handler

<Window
    x:Class="CheckboxTest.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:CheckboxTest"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Title="ContentDialog Focus Test">

    <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
        <ContentDialog
            x:Name="DeleteConfirmationDialog"
            Title="Delete file"
            PrimaryButtonText="Move to Recycle Bin"
            CloseButtonText="Cancel"
            DefaultButton="Close"
            Opened="OnContentDialogOpened">
            <StackPanel VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Spacing="12">
                <TextBlock TextWrapping="Wrap" Text="Are you sure you want to move file 'somefile.jpg' to the Recycle Bin?" />
                <CheckBox x:Name="DeleteDontAskCheckbox" IsTabStop="False"  Content="Don't ask me again" />
            </StackPanel>
        </ContentDialog>
    </StackPanel>
</Window>

Upvotes: 0

Andrew KeepCoding
Andrew KeepCoding

Reputation: 13203

I'd just set IsTabStop to False.

<CheckBox x:Name="DeleteDontAskCheckbox"
    Content="Don't ask me again"
    IsTabStop="False" />

There might be other ways to do this, but they'll be complicated and messy.

Upvotes: 2

Related Questions