diff --git a/CHANGELOG.md b/CHANGELOG.md index cce0a72..62abaee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- **Screen readers read the feed tree properly.** Every feed and folder was announced as + "RSSReaderWPF.FeedItem" instead of its name. Each one now says its own name. + ## [1.4.0] - 2026-10-01 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index bc4f4c4..9036823 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ Flow: ### Accessibility constraints — treat these as load-bearing - **Neither items control is its own tab stop.** `IsTabStop="False"` on both `FeedTree` and `HeadlinesList`, plus an `ItemContainerStyle` that sets `IsTabStop="True"` on `TreeViewItem` (unlike `ListBoxItem`, it is not one by default). With `TabNavigation="Once"` each panel is a single stop that lands on an item. Setting `IsTabStop="True"` on a container reintroduces the 1.1.0 Shift+Tab bug: focus lands on a container that reports no name, value or state, and the `GotFocus` handler pushes it straight back in, so Shift+Tab appears to do nothing. `tests/RSSQuick.Tests/TabOrderTests.cs` measures this — every test there was verified to fail against the unfixed window. +- **Every data-bound item needs a name, twice over.** A `TreeViewItem`, `ListBoxItem` or `ComboBoxItem` built from a data object is named for UI Automation by that object's `ToString` unless its `ItemContainerStyle` binds `AutomationProperties.Name`, and that is the class name: the feed tree shipped in 1.4.0 reading "RSSReaderWPF.FeedItem" for every row, and the Subscribe dialog's folder list once read out a whole record. Bind the name in the container style, and give the model a `ToString` that returns its visible text, for rows a virtualized list has no container for. `AccessibleNameTests` checks both. - **The `GotFocus` handlers are guarded on `e.OriginalSource`.** They redirect only focus that landed on the container itself. Without the guard they run for every focus change bubbling through the panel, so each arrow-key step re-focuses the row it just left. - `FeedText.CleanTitle()` strips zero-width characters (U+200B/C/D, U+FEFF, U+2060), normalizes exotic spaces (U+00A0, U+2009, U+202F) to plain spaces, replaces control characters with a space, and collapses whitespace. Invisible characters and stray whitespace render as confusing blank cells on a braille display. Do not bypass it for text that reaches a headline. - **The menu bar is not in the tab ring** (`KeyboardNavigation.TabNavigation="None"`, `IsTabStop="False"`); Alt or F10 reaches it. Its items are never disabled, because WPF skips a disabled menu item when arrowing and a reader would never learn it was there — each command says in the status bar why it did nothing instead. Access keys avoid D, B and U, which are Alt+D, Alt+B and Alt+U bindings. A key shown in a menu must be an `InputBinding`; `MenuAndFeedCommandsTests` checks. diff --git a/src/RSSQuick/MainWindow.xaml b/src/RSSQuick/MainWindow.xaml index b0450bd..f9128a0 100644 --- a/src/RSSQuick/MainWindow.xaml +++ b/src/RSSQuick/MainWindow.xaml @@ -123,6 +123,11 @@ is not one by default, so taking IsTabStop off the TreeView alone dropped the whole panel out of the tab ring. --> + + diff --git a/src/RSSQuick/Models/ArticleItem.cs b/src/RSSQuick/Models/ArticleItem.cs index 2051d24..a3b7559 100644 --- a/src/RSSQuick/Models/ArticleItem.cs +++ b/src/RSSQuick/Models/ArticleItem.cs @@ -28,6 +28,14 @@ public string Title set { _title = value; OnPropertyChanged(); } } + /// The title alone. + /// + /// Load-bearing: where WPF has no item container to ask, such as a row scrolled out of a + /// virtualized list, it names the item for a screen reader by its ToString, which would + /// otherwise be the class name. The XAML binds the name too; this is the fallback. + /// + public override string ToString() => Title; + public string Link { get => _link; diff --git a/src/RSSQuick/Models/FeedItem.cs b/src/RSSQuick/Models/FeedItem.cs index de52962..132cdc3 100644 --- a/src/RSSQuick/Models/FeedItem.cs +++ b/src/RSSQuick/Models/FeedItem.cs @@ -21,6 +21,14 @@ public string Title set { _title = value; OnPropertyChanged(); } } + /// The title alone. + /// + /// Load-bearing: where WPF has no item container to ask, such as a row scrolled out of a + /// virtualized list, it names the item for a screen reader by its ToString, which would + /// otherwise be the class name. The XAML binds the name too; this is the fallback. + /// + public override string ToString() => Title; + /// The feed URL. Empty for a folder. public string Url { diff --git a/tests/RSSQuick.Tests/AccessibleNameTests.cs b/tests/RSSQuick.Tests/AccessibleNameTests.cs new file mode 100644 index 0000000..7969835 --- /dev/null +++ b/tests/RSSQuick.Tests/AccessibleNameTests.cs @@ -0,0 +1,63 @@ +using System.Linq; +using System.Windows; +using System.Windows.Automation.Peers; +using System.Windows.Controls; +using RSSReaderWPF; + +namespace RSSQuick.Tests; + +/// +/// Every item a screen reader lands on reports the text the reader sees. +/// +/// +/// The feed tree shipped in 1.4.0 with no name on its items, so UI Automation fell back to the +/// data object's ToString and NVDA read "RSSReaderWPF.FeedItem" for every feed and folder. The +/// tree tests only ever checked where focus went, never what it was called. Names are read here +/// the way a screen reader reaches them: as the children of the tree's or list's own automation +/// peer, which are wrappers around the data items rather than the containers themselves. +/// +[Collection(WpfCollection.Name)] +public class AccessibleNameTests +{ + [WpfFact] + public void Feed_tree_items_are_named_by_their_title() + { + using var ui = new FocusHarness(); + var folder = new FeedItem { Title = "Technology", IsCategory = true }; + folder.Children.Add(new FeedItem { Title = "Ars Technica" }); + ui.SetFeeds(folder, new FeedItem { Title = "BBC News" }); + + var top = ChildrenOf(ui.FeedTree); + Assert.Equal(new[] { "Technology", "BBC News" }, top.Select(p => p.GetName())); + + var container = (TreeViewItem)ui.FeedTree.ItemContainerGenerator.ContainerFromIndex(0); + container.IsExpanded = true; + ui.Window.UpdateLayout(); + ui.Drain(); + Assert.Equal("Ars Technica", ChildrenOf(ui.FeedTree)[0].GetChildren() + .Single(p => p.GetAutomationControlType() == AutomationControlType.TreeItem).GetName()); + } + + [WpfFact] + public void Headlines_are_named_by_their_title() + { + using var ui = new FocusHarness(); + Assert.Equal(new[] { "Headline one", "Headline two" }, + ChildrenOf(ui.Headlines).Select(p => p.GetName())); + } + + [Fact] + public void Items_without_a_container_fall_back_to_their_title_not_the_class_name() + { + // A row scrolled out of a virtualized list has no container, and WPF then names it by + // the item's ToString. + Assert.Equal("BBC News", new FeedItem { Title = "BBC News" }.ToString()); + Assert.Equal("Storm warning", new ArticleItem { Title = "Storm warning" }.ToString()); + } + + private static AutomationPeer[] ChildrenOf(UIElement control) => + UIElementAutomationPeer.CreatePeerForElement(control).GetChildren() + .Where(p => p.GetAutomationControlType() is AutomationControlType.TreeItem + or AutomationControlType.ListItem) + .ToArray(); +}