From afc3e1eec22c8e306fbdc9adbd90d7bde708fdd8 Mon Sep 17 00:00:00 2001 From: makhlwf <78276231+makhlwf@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:02:53 +0200 Subject: [PATCH] Fix screen reader button accessibility and category navigation (#4944) * Fix UI automation peer wrapping and category focusability * Add accessibility name to SettingsButton * Avoid nesting ScrollViewer in generated panels with outer viewers Inspect targetGrid and its parent hierarchy in Invoke-WPFUIElements to avoid adding an inner ScrollViewer when an outer ScrollViewer already exists. --- .../Find-TweaksByNameOrDescription.ps1 | 52 ++++++++------ functions/public/Invoke-WPFUIElements.ps1 | 71 ++++++++++++------- pester/lazy-tabs.Tests.ps1 | 7 ++ xaml/inputXML.xaml | 2 + 4 files changed, 86 insertions(+), 46 deletions(-) diff --git a/functions/private/Find-TweaksByNameOrDescription.ps1 b/functions/private/Find-TweaksByNameOrDescription.ps1 index 9ca884b4..a8dd52ef 100644 --- a/functions/private/Find-TweaksByNameOrDescription.ps1 +++ b/functions/private/Find-TweaksByNameOrDescription.ps1 @@ -90,18 +90,25 @@ function Find-TweaksByNameOrDescription { } if ($dockPanel -is [Windows.Controls.DockPanel]) { - $itemsControl = $null - $itemsControl = $dockPanel.Children | Where-Object { $_ -is [Windows.Controls.ItemsControl] } | Select-Object -First 1 + $container = $dockPanel.Children | Where-Object { $_ -is [Windows.Controls.ItemsControl] -or $_ -is [Windows.Controls.StackPanel] -or $_ -is [Windows.Controls.ScrollViewer] -or $_.GetType().Name -eq "ItemsControl" } | Select-Object -First 1 - if ($null -ne $itemsControl) { + if ($null -ne $container) { + $targetPanel = if ($container.PSObject.Properties['Content'] -and $null -ne $container.Content) { $container.Content } else { $container } + $items = $null + if ($targetPanel -is [Windows.Controls.ItemsControl] -or $targetPanel.GetType().Name -eq "ItemsControl") { + $items = $targetPanel.Items + } + else { + $items = $targetPanel.Children + } # Show all items in the category - foreach ($item in $itemsControl.Items) { + foreach ($item in $items) { if ($null -ne $item) { - # Check if it's a category label (first Label in the ItemsControl) - if ($item -is [Windows.Controls.Label]) { + # Check if it's a category label (first Label in the container) + if ($item -is [Windows.Controls.Label] -or $item.GetType().Name -eq "Label") { $item.Visibility = [Windows.Visibility]::Visible } - elseif ($item -is [Windows.Controls.DockPanel] -or $item -is [Windows.Controls.StackPanel]) { + elseif ($item -is [Windows.Controls.DockPanel] -or $item -is [Windows.Controls.StackPanel] -or $item.GetType().Name -eq "DockPanel" -or $item.GetType().Name -eq "StackPanel") { # Show all checkbox containers $item.Visibility = [Windows.Visibility]::Visible } @@ -143,16 +150,21 @@ function Find-TweaksByNameOrDescription { } if ($dockPanel -is [Windows.Controls.DockPanel]) { - $itemsControl = $null - $itemsControl = $dockPanel.Children | Where-Object { $_ -is [Windows.Controls.ItemsControl] } | Select-Object -First 1 + $container = $dockPanel.Children | Where-Object { $_ -is [Windows.Controls.ItemsControl] -or $_ -is [Windows.Controls.StackPanel] -or $_ -is [Windows.Controls.ScrollViewer] -or $_.GetType().Name -eq "ItemsControl" } | Select-Object -First 1 - if ($null -ne $itemsControl) { + if ($null -ne $container) { $categoryLabel = $null - # Process all items (checkboxes, labels, panels) in the ItemsControl - for ($i = 0; $i -lt $itemsControl.Items.Count; $i++) { - $item = $itemsControl.Items[$i] - + $targetPanel = if ($container.PSObject.Properties['Content'] -and $null -ne $container.Content) { $container.Content } else { $container } + $items = $null + if ($targetPanel -is [Windows.Controls.ItemsControl] -or $targetPanel.GetType().Name -eq "ItemsControl") { + $items = $targetPanel.Items + } + else { + $items = $targetPanel.Children + } + # Process all items (checkboxes, labels, panels) in the container + foreach ($item in $items) { if ($null -eq $item) { continue } @@ -161,7 +173,7 @@ function Find-TweaksByNameOrDescription { # Check if this is a category label (usually first Label) # ------------------------------------------------------------ - if ($item -is [Windows.Controls.Label]) { + if ($item -is [Windows.Controls.Label] -or $item.GetType().Name -eq "Label") { $categoryLabel = $item # Initially hide category label; show it only if matches found $item.Visibility = [Windows.Visibility]::Collapsed @@ -171,13 +183,13 @@ function Find-TweaksByNameOrDescription { # Check if this is a DockPanel containing a tweak checkbox # ------------------------------------------------------------ - elseif ($item -is [Windows.Controls.DockPanel]) { + elseif ($item -is [Windows.Controls.DockPanel] -or $item.GetType().Name -eq "DockPanel") { $checkbox = $null $label = $null # Safely extract checkbox and label - $checkbox = $item.Children | Where-Object { $_ -is [Windows.Controls.CheckBox] } | Select-Object -First 1 - $label = $item.Children | Where-Object { $_ -is [Windows.Controls.Label] } | Select-Object -First 1 + $checkbox = $item.Children | Where-Object { $_ -is [Windows.Controls.CheckBox] -or $_.GetType().Name -eq "CheckBox" } | Select-Object -First 1 + $label = $item.Children | Where-Object { $_ -is [Windows.Controls.Label] -or $_.GetType().Name -eq "Label" } | Select-Object -First 1 # Check if tweak matches search criteria $itemMatches = $false @@ -221,9 +233,9 @@ function Find-TweaksByNameOrDescription { # Check if this is a StackPanel containing a tweak checkbox # ------------------------------------------------------------ - elseif ($item -is [Windows.Controls.StackPanel]) { + elseif ($item -is [Windows.Controls.StackPanel] -or $item.GetType().Name -eq "StackPanel") { $checkbox = $null - $checkbox = $item.Children | Where-Object { $_ -is [Windows.Controls.CheckBox] } | Select-Object -First 1 + $checkbox = $item.Children | Where-Object { $_ -is [Windows.Controls.CheckBox] -or $_.GetType().Name -eq "CheckBox" } | Select-Object -First 1 $itemMatches = $false diff --git a/functions/public/Invoke-WPFUIElements.ps1 b/functions/public/Invoke-WPFUIElements.ps1 index f3b2d717..eb50fae0 100644 --- a/functions/public/Invoke-WPFUIElements.ps1 +++ b/functions/public/Invoke-WPFUIElements.ps1 @@ -49,7 +49,7 @@ function Invoke-WPFUIElements { # Add ColumnDefinitions to the target Grid for ($i = 0; $i -lt $columncount; $i++) { $colDef = New-Object Windows.Controls.ColumnDefinition - $colDef.Width = New-Object Windows.GridLength(1, [Windows.GridUnitType]::Star) + $colDef.Width = New-Object System.Windows.GridLength([double]1, [System.Windows.GridUnitType]::Star) $targetGrid.ColumnDefinitions.Add($colDef) | Out-Null } @@ -113,36 +113,55 @@ function Invoke-WPFUIElements { $dockPanelContainer = New-Object Windows.Controls.DockPanel $border.Child = $dockPanelContainer - # Create an ItemsControl for application content - $itemsControl = New-Object Windows.Controls.ItemsControl - $itemsControl.HorizontalAlignment = 'Stretch' - $itemsControl.VerticalAlignment = 'Stretch' + # Create a StackPanel for application content controls + $stackPanelContainer = New-Object Windows.Controls.StackPanel + $stackPanelContainer.HorizontalAlignment = 'Stretch' + $stackPanelContainer.VerticalAlignment = 'Stretch' - # Set the ItemsPanel to a VirtualizingStackPanel - $itemsPanelTemplate = New-Object Windows.Controls.ItemsPanelTemplate - $factory = New-Object Windows.FrameworkElementFactory ([Windows.Controls.VirtualizingStackPanel]) - $itemsPanelTemplate.VisualTree = $factory - $itemsControl.ItemsPanel = $itemsPanelTemplate + # Check if the target grid (or any ancestor) is already inside a ScrollViewer + $hasOuterScrollViewer = $false + $currentElement = $targetGrid + while ($null -ne $currentElement) { + if ($currentElement -is [System.Windows.Controls.ScrollViewer] -or $currentElement.GetType().Name -eq "ScrollViewer") { + $hasOuterScrollViewer = $true + break + } + $currentElement = $currentElement.Parent + } - # Set virtualization properties - $itemsControl.SetValue([Windows.Controls.VirtualizingStackPanel]::IsVirtualizingProperty, $true) - $itemsControl.SetValue([Windows.Controls.VirtualizingStackPanel]::VirtualizationModeProperty, [Windows.Controls.VirtualizationMode]::Recycling) + if ($hasOuterScrollViewer) { + # Add StackPanel directly to DockPanel without nesting a ScrollViewer + [Windows.Controls.DockPanel]::SetDock($stackPanelContainer, [Windows.Controls.Dock]::Bottom) + $dockPanelContainer.Children.Add($stackPanelContainer) | Out-Null + } + else { + # Create a ScrollViewer for targets that do not already have an outer ScrollViewer + $scrollViewer = New-Object Windows.Controls.ScrollViewer + $scrollViewer.VerticalScrollBarVisibility = "Auto" + $scrollViewer.HorizontalScrollBarVisibility = "Disabled" + $scrollViewer.HorizontalAlignment = 'Stretch' + $scrollViewer.VerticalAlignment = 'Stretch' + $scrollViewer.Content = $stackPanelContainer - # Add the ItemsControl directly to the DockPanel - [Windows.Controls.DockPanel]::SetDock($itemsControl, [Windows.Controls.Dock]::Bottom) - $dockPanelContainer.Children.Add($itemsControl) | Out-Null + [Windows.Controls.DockPanel]::SetDock($scrollViewer, [Windows.Controls.Dock]::Bottom) + $dockPanelContainer.Children.Add($scrollViewer) | Out-Null + } $panelcount++ - # Now proceed with adding category labels and entries to $itemsControl + # Now proceed with adding category labels and entries to $stackPanelContainer foreach ($category in ($organizedData[$panelKey].Keys | Sort-Object)) { $count++ $label = New-Object Windows.Controls.Label - $label.Content = $category -replace ".*__", "" + $categoryCleanName = $category -replace ".*__", "" + $label.Content = $categoryCleanName + $label.Focusable = $true + $label.IsTabStop = $true + [System.Windows.Automation.AutomationProperties]::SetName($label, $categoryCleanName) $label.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "HeaderFontSize") $label.SetResourceReference([Windows.Controls.Control]::FontFamilyProperty, "HeaderFontFamily") $label.UseLayoutRounding = $true - $itemsControl.Items.Add($label) | Out-Null + $stackPanelContainer.Children.Add($label) | Out-Null $sync[$category] = $label # Sort entries by type (checkboxes first, then buttons, then comboboxes, notes last) and then alphabetically by Content @@ -177,7 +196,7 @@ function Invoke-WPFUIElements { $label.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, "MainForegroundColor") $label.UseLayoutRounding = $true $dockPanel.Children.Add($label) | Out-Null - $itemsControl.Items.Add($dockPanel) | Out-Null + $stackPanelContainer.Children.Add($dockPanel) | Out-Null $sync[$entryInfo.Name] = $checkBox $sync[$entryInfo.Name].IsChecked = (Get-WinUtilToggleStatus $entryInfo.Name) @@ -215,7 +234,7 @@ function Invoke-WPFUIElements { contentOff = if ($entryInfo.Content.Count -ge 2) { $entryInfo.Content[1] } else { $contentOn } } - $itemsControl.Items.Add($toggleButton) | Out-Null + $stackPanelContainer.Children.Add($toggleButton) | Out-Null $sync[$entryInfo.Name] = $toggleButton @@ -295,7 +314,7 @@ function Invoke-WPFUIElements { } $horizontalStackPanel.Children.Add($comboBox) | Out-Null - $itemsControl.Items.Add($horizontalStackPanel) | Out-Null + $stackPanelContainer.Children.Add($horizontalStackPanel) | Out-Null if ($entryInfo.Registry -and @($entryInfo.Registry)[0].Values) { try { @@ -387,7 +406,7 @@ function Invoke-WPFUIElements { $button.Width = [math]::Max($baseWidth, 350) } [System.Windows.Automation.AutomationProperties]::SetName($button, $entryInfo.Content) - $itemsControl.Items.Add($button) | Out-Null + $stackPanelContainer.Children.Add($button) | Out-Null $sync[$entryInfo.Name] = $button @@ -414,7 +433,7 @@ function Invoke-WPFUIElements { $radioButtonGroups[$entryInfo.GroupName] = $groupStackPanel # Add the group container to the ItemsControl - $itemsControl.Items.Add($groupStackPanel) | Out-Null + $stackPanelContainer.Children.Add($groupStackPanel) | Out-Null } else { # Retrieve the existing group container @@ -459,7 +478,7 @@ function Invoke-WPFUIElements { $textBlock.Inlines.Add($bulletBadge) $textBlock.Inlines.Add($textRun) - $itemsControl.Items.Add($textBlock) | Out-Null + $stackPanelContainer.Children.Add($textBlock) | Out-Null } default { @@ -519,7 +538,7 @@ function Invoke-WPFUIElements { $sync[$textBlock.Name] = $textBlock } - $itemsControl.Items.Add($horizontalStackPanel) | Out-Null + $stackPanelContainer.Children.Add($horizontalStackPanel) | Out-Null $sync[$entryInfo.Name] = $checkBox $sync[$entryInfo.Name].Add_Checked({ diff --git a/pester/lazy-tabs.Tests.ps1 b/pester/lazy-tabs.Tests.ps1 index 2544db83..1c4db6c6 100644 --- a/pester/lazy-tabs.Tests.ps1 +++ b/pester/lazy-tabs.Tests.ps1 @@ -138,4 +138,11 @@ Describe "Startup lazy tab wiring" { $rendererScript | Should -Match '(?s)if \(\$entryInfo\.Link\).*\$textBlock\.Add_MouseUp\(\{.*Start-Process \$Sender\.ToolTip -ErrorAction Stop' $mainScript | Should -Not -Match '\.Name\.EndsWith\("Link"\)' } + + It "checks for an existing outer ScrollViewer before nesting an inner ScrollViewer" { + $rendererScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIElements.ps1") -Raw + + $rendererScript | Should -Match '\$hasOuterScrollViewer' + $rendererScript | Should -Match 'if\s*\(\$hasOuterScrollViewer\)' + } } diff --git a/xaml/inputXML.xaml b/xaml/inputXML.xaml index b3b58d2d..b5b0dc7a 100644 --- a/xaml/inputXML.xaml +++ b/xaml/inputXML.xaml @@ -1257,6 +1257,8 @@ HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,2,0" FontFamily="Segoe MDL2 Assets" + ToolTip="Settings" + AutomationProperties.Name="Settings" Content=""/>