From 725bd8e925bda9e8cd75e3710f69c85e51294a90 Mon Sep 17 00:00:00 2001 From: Talon Date: Mon, 22 Jun 2026 13:34:26 +0200 Subject: [PATCH] fix(windows): screen-reader accessibility for context menus and channel tree - ContextMenuStrip: select first item on Opened so keyboard-invoked menus (Shift+F10 / Apps key) raise the UIA focus event immediately instead of staying silent until the first arrow key. - Name the SplitContainer/SplitterPanel containers so screen readers announce orientation instead of a stack of anonymous pane nodes. - Take the resize splitters out of the Tab cycle (TabStop = false) so focus moves control-to-control. - RefreshChannelTree: restore keyboard focus and re-announce the current node after a Nodes.Clear()/rebuild, so the tree no longer loses focus when channels/users change. Co-Authored-By: Claude Opus 4.8 --- .../VoiceCat.App/Forms/MainForm.Designer.cs | 11 +++++++ .../windows/VoiceCat.App/Forms/MainForm.cs | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs b/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs index f156034..69f0c4c 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs @@ -140,6 +140,13 @@ partial class MainForm splitLeft.Panel1MinSize = 100; splitLeft.Panel2MinSize = 80; splitLeft.TabIndex = 0; + // Keep the resize splitter out of the Tab cycle so focus moves control-to-control. + splitLeft.TabStop = false; + // Name the otherwise-anonymous "pane" containers so screen readers announce + // orientation instead of a stack of unnamed panes. + splitLeft.AccessibleName = "Channels and users"; + splitLeft.Panel1.AccessibleName = "Channels"; + splitLeft.Panel2.AccessibleName = "Users"; splitLeft.Panel1.Controls.Add(tvChannels); splitLeft.Panel1.Controls.Add(lblChannels); splitLeft.Panel2.Controls.Add(lstUsers); @@ -214,6 +221,10 @@ partial class MainForm splitMain.Dock = DockStyle.Fill; splitMain.Panel1MinSize = 150; splitMain.TabIndex = 1; + splitMain.TabStop = false; + splitMain.AccessibleName = "Main"; + splitMain.Panel1.AccessibleName = "Channels and users"; + splitMain.Panel2.AccessibleName = "Chat and activity"; splitMain.Panel1.Controls.Add(splitLeft); splitMain.Panel2.Controls.Add(tblRight); diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs index 07f85d6..71072dc 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs @@ -201,6 +201,7 @@ public partial class MainForm : Form ctx.Items.Add("&Delete channel...", null, (_, _) => DeleteSelectedChannel()); } }; + ctx.Opened += (_, _) => SelectFirstMenuItem(ctx); tvChannels.ContextMenuStrip = ctx; } @@ -247,9 +248,26 @@ public partial class MainForm : Form } } }; + ctx.Opened += (_, _) => SelectFirstMenuItem(ctx); lstUsers.ContextMenuStrip = ctx; } + // Work around the WinForms ContextMenuStrip accessibility bug: when opened by + // keyboard (Shift+F10 / Apps key) the focused item is not set, so screen readers + // stay silent until the first arrow key. Selecting the first item ourselves on + // Opened raises the UIA focus event immediately. + private static void SelectFirstMenuItem(ContextMenuStrip ctx) + { + foreach (ToolStripItem item in ctx.Items) + { + if (item is ToolStripMenuItem && item.Enabled) + { + item.Select(); + break; + } + } + } + // ── Event dispatch ──────────────────────────────────────────────────────── private void OnEvent(VoiceCatEvent ev) @@ -468,6 +486,7 @@ public partial class MainForm : Form private void RefreshChannelTree() { uint toSelect = tvChannels.SelectedNode?.Tag is uint s ? s : _currentChannelId; + bool hadFocus = tvChannels.Focused; tvChannels.BeginUpdate(); tvChannels.Nodes.Clear(); @@ -493,6 +512,18 @@ public partial class MainForm : Form tvChannels.ExpandAll(); SeekAndSelect(tvChannels.Nodes, toSelect); tvChannels.EndUpdate(); + + // A Nodes.Clear()/rebuild can drop keyboard focus and leave the screen reader + // without a current node. If the tree was focused before the refresh, restore + // focus and re-announce the now-current node (null-then-reselect forces UIA to + // fire a fresh focus event). + if (hadFocus && tvChannels.SelectedNode != null) + { + var node = tvChannels.SelectedNode; + tvChannels.Focus(); + tvChannels.SelectedNode = null; + tvChannels.SelectedNode = node; + } } private bool SeekAndSelect(TreeNodeCollection nodes, uint channelId)