Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/RSSQuick/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<Setter Property="IsTabStop" Value="True"/>
<!-- A TreeViewItem whose header is a data object is named by that object's
ToString, which here is the class name: screen readers said
"RSSReaderWPF.FeedItem" for every feed and folder. The text shown by
the template below is not used, so the name has to be set. -->
<Setter Property="AutomationProperties.Name" Value="{Binding Title}"/>
</Style>
</TreeView.ItemContainerStyle>
<TreeView.ItemTemplate>
Expand Down
8 changes: 8 additions & 0 deletions src/RSSQuick/Models/ArticleItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ public string Title
set { _title = value; OnPropertyChanged(); }
}

/// <summary>The title alone.</summary>
/// <remarks>
/// 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.
/// </remarks>
public override string ToString() => Title;

public string Link
{
get => _link;
Expand Down
8 changes: 8 additions & 0 deletions src/RSSQuick/Models/FeedItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ public string Title
set { _title = value; OnPropertyChanged(); }
}

/// <summary>The title alone.</summary>
/// <remarks>
/// 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.
/// </remarks>
public override string ToString() => Title;

/// <summary>The feed URL. Empty for a folder.</summary>
public string Url
{
Expand Down
63 changes: 63 additions & 0 deletions tests/RSSQuick.Tests/AccessibleNameTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Linq;
using System.Windows;
using System.Windows.Automation.Peers;
using System.Windows.Controls;
using RSSReaderWPF;

namespace RSSQuick.Tests;

/// <summary>
/// Every item a screen reader lands on reports the text the reader sees.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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();
}
Loading