From 84334280368e7878933f67d588cabbc9fe6cda87 Mon Sep 17 00:00:00 2001 From: attila Date: Fri, 4 Jul 2025 15:38:18 +0200 Subject: [PATCH] Accessibility: Make the FocusTraverser navigate onto disabled components With this change disabled components become discoverable by screen readers, similarly to how OS native user interface components behave by default. The KeyboardFocusTraverser will still skip disabled components so this does not affect keyboard navigation without screen readers. --- BREAKING_CHANGES.md | 36 ++++++++++++++++ .../components/juce_FocusTraverser.cpp | 43 +++++++++++++------ .../components/juce_FocusTraverser.h | 22 ++++++++++ .../detail/juce_FocusHelpers.h | 18 +++++--- .../keyboard/juce_KeyboardFocusTraverser.cpp | 6 ++- 5 files changed, 104 insertions(+), 21 deletions(-) diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index 12298e9675..226fef537b 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -2,6 +2,42 @@ # develop +## Change + +The behaviour of the default constructed FocusTraverser objects has changed, and +they will now navigate onto disabled components. This only affects navigation by +screen readers and not general keyboard navigation, as the latter depends on the +KeyboardFocusTraverser class. + +**Possible Issues** + +Disabled child components of focus containers that used the JUCE default +FocusTraverser will now be discoverable by screen readers. They will accept +accessibility focus, their title will be reported as well as their disabled +state. + +Children of components that returned a custom ComponentTraverser object are not +affected. + +**Workaround** + +If you wish to hide disabled components from screen readers, you can restore the +old behaviour by overriding `Component::createFocusTraverser()` for your focus +containers, and returning a FocusTraverser object created using the +`FocusTraverser::SkipDisabledComponents::yes` argument. + +**Rationale** + +Disabled components are typically rendered in a dimmed or inactive state, but +are still prominently visible for sighted users. The old behaviour made these +components entirely missing from the accessibility tree, making them +non-discoverable with screen readers. + +This was in contrast to the behaviour of native OS components, that are still +accessible using screen readers, but their disabled/dimmed state is also +reported. + + ## Change The default Visual Studio project settings for "Debug Information Format" have diff --git a/modules/juce_gui_basics/components/juce_FocusTraverser.cpp b/modules/juce_gui_basics/components/juce_FocusTraverser.cpp index 44dd629a30..0ece10edc7 100644 --- a/modules/juce_gui_basics/components/juce_FocusTraverser.cpp +++ b/modules/juce_gui_basics/components/juce_FocusTraverser.cpp @@ -36,6 +36,11 @@ namespace juce { //============================================================================== +FocusTraverser::FocusTraverser (SkipDisabledComponents skipDisabledComponentsIn) + : skipDisabledComponents (skipDisabledComponentsIn) +{ +} + Component* FocusTraverser::getNextComponent (Component* current) { jassert (current != nullptr); @@ -43,7 +48,8 @@ Component* FocusTraverser::getNextComponent (Component* current) return detail::FocusHelpers::navigateFocus (current, current->findFocusContainer(), detail::FocusHelpers::NavigationDirection::forwards, - &Component::isFocusContainer); + &Component::isFocusContainer, + skipDisabledComponents); } Component* FocusTraverser::getPreviousComponent (Component* current) @@ -53,7 +59,8 @@ Component* FocusTraverser::getPreviousComponent (Component* current) return detail::FocusHelpers::navigateFocus (current, current->findFocusContainer(), detail::FocusHelpers::NavigationDirection::backwards, - &Component::isFocusContainer); + &Component::isFocusContainer, + skipDisabledComponents); } Component* FocusTraverser::getDefaultComponent (Component* parentComponent) @@ -61,9 +68,11 @@ Component* FocusTraverser::getDefaultComponent (Component* parentComponent) if (parentComponent != nullptr) { std::vector components; + detail::FocusHelpers::findAllComponents (parentComponent, components, - &Component::isFocusContainer); + &Component::isFocusContainer, + skipDisabledComponents); if (! components.empty()) return components.front(); @@ -77,7 +86,8 @@ std::vector FocusTraverser::getAllComponents (Component* parentCompo std::vector components; detail::FocusHelpers::findAllComponents (parentComponent, components, - &Component::isFocusContainer); + &Component::isFocusContainer, + skipDisabledComponents); return components; } @@ -116,14 +126,23 @@ struct FocusTraverserTests final : public UnitTest [] (const Component* c1, const Component& c2) { return c1 == &c2; })); } - beginTest ("Disabled components are ignored"); + beginTest ("Disabled components are not ignored by default"); { - checkIgnored ([] (Component& c) { c.setEnabled (false); }); + TestComponent parent; + parent.children[2].setEnabled (false); + parent.children[5].setEnabled (false); + expect (traverser.getAllComponents (&parent).size() == parent.children.size()); + } + + beginTest ("Disabled components can be ignored"); + { + FocusTraverser ignoringTraverser { FocusTraverser::SkipDisabledComponents::yes }; + checkIgnored ([] (Component& c) { c.setEnabled (false); }, ignoringTraverser); } beginTest ("Invisible components are ignored"); { - checkIgnored ([] (Component& c) { c.setVisible (false); }); + checkIgnored ([] (Component& c) { c.setVisible (false); }, traverser); } beginTest ("Explicit focus order comes before unspecified"); @@ -253,21 +272,21 @@ private: } } - void checkIgnored (const std::function& makeIgnored) + void checkIgnored (const std::function& makeIgnored, FocusTraverser& traverserToUse) { TestComponent parent; auto iter = parent.children.begin(); makeIgnored (*iter); - expect (traverser.getDefaultComponent (&parent) == std::addressof (*std::next (iter))); + expect (traverserToUse.getDefaultComponent (&parent) == std::addressof (*std::next (iter))); iter += 5; makeIgnored (*iter); - expect (traverser.getNextComponent (std::addressof (*std::prev (iter))) == std::addressof (*std::next (iter))); - expect (traverser.getPreviousComponent (std::addressof (*std::next (iter))) == std::addressof (*std::prev (iter))); + expect (traverserToUse.getNextComponent (std::addressof (*std::prev (iter))) == std::addressof (*std::next (iter))); + expect (traverserToUse.getPreviousComponent (std::addressof (*std::next (iter))) == std::addressof (*std::prev (iter))); - auto allComponents = traverser.getAllComponents (&parent); + auto allComponents = traverserToUse.getAllComponents (&parent); expect (std::find (allComponents.cbegin(), allComponents.cend(), &parent.children.front()) == allComponents.cend()); expect (std::find (allComponents.cbegin(), allComponents.cend(), std::addressof (*iter)) == allComponents.cend()); diff --git a/modules/juce_gui_basics/components/juce_FocusTraverser.h b/modules/juce_gui_basics/components/juce_FocusTraverser.h index 86d67952ae..627b7b069e 100644 --- a/modules/juce_gui_basics/components/juce_FocusTraverser.h +++ b/modules/juce_gui_basics/components/juce_FocusTraverser.h @@ -62,6 +62,25 @@ namespace juce class JUCE_API FocusTraverser : public ComponentTraverser { public: + /** Controls whether the FocusTraverser will allow navigation to disabled components. + */ + enum class SkipDisabledComponents + { + no, ///< Disabled components can receive focus. + yes ///< Disabled components can't receive focus. + }; + + /** Creates a FocusTraverser that will not skip disabled components. */ + FocusTraverser() = default; + + /** Creates a FocusTraverser. + + @param skipDisabledComponents If set to SkipDisabledComponents::yes, the traverser will ignore + disabled components. This makes such components non-discoverable + using screen readers. + */ + explicit FocusTraverser (SkipDisabledComponents skipDisabledComponents); + /** Destructor. */ ~FocusTraverser() override = default; @@ -97,6 +116,9 @@ public: The default implementation will return all visible and enabled child components. */ std::vector getAllComponents (Component* parentComponent) override; + +private: + SkipDisabledComponents skipDisabledComponents = SkipDisabledComponents::no; }; } // namespace juce diff --git a/modules/juce_gui_basics/detail/juce_FocusHelpers.h b/modules/juce_gui_basics/detail/juce_FocusHelpers.h index 7b64f36cc5..1a3cc24ad9 100644 --- a/modules/juce_gui_basics/detail/juce_FocusHelpers.h +++ b/modules/juce_gui_basics/detail/juce_FocusHelpers.h @@ -45,10 +45,10 @@ struct FocusHelpers return order > 0 ? order : std::numeric_limits::max(); } - template static void findAllComponents (const Component* parent, std::vector& components, - FocusContainerFn isFocusContainer) + bool (Component::* isFocusContainer)() const, + FocusTraverser::SkipDisabledComponents skipDisabledComponents) { if (parent == nullptr || parent->getNumChildComponents() == 0) return; @@ -56,8 +56,12 @@ struct FocusHelpers std::vector localComponents; for (auto* c : parent->getChildren()) - if (c->isVisible() && c->isEnabled()) + { + constexpr auto no = FocusTraverser::SkipDisabledComponents::no; + + if (c->isVisible() && (c->isEnabled() || skipDisabledComponents == no)) localComponents.push_back (c); + } const auto compareComponents = [&] (const Component* a, const Component* b) { @@ -81,23 +85,23 @@ struct FocusHelpers components.push_back (c); if (! (c->*isFocusContainer)()) - findAllComponents (c, components, isFocusContainer); + findAllComponents (c, components, isFocusContainer, skipDisabledComponents); } } enum class NavigationDirection { forwards, backwards }; - template static Component* navigateFocus (const Component* current, const Component* focusContainer, NavigationDirection direction, - FocusContainerFn isFocusContainer) + bool (Component::* isFocusContainer)() const, + FocusTraverser::SkipDisabledComponents skipDisabledComponents) { if (focusContainer == nullptr) return nullptr; std::vector components; - findAllComponents (focusContainer, components, isFocusContainer); + findAllComponents (focusContainer, components, isFocusContainer, skipDisabledComponents); const auto iter = std::find (components.cbegin(), components.cend(), current); diff --git a/modules/juce_gui_basics/keyboard/juce_KeyboardFocusTraverser.cpp b/modules/juce_gui_basics/keyboard/juce_KeyboardFocusTraverser.cpp index 14098abc70..b76a65026b 100644 --- a/modules/juce_gui_basics/keyboard/juce_KeyboardFocusTraverser.cpp +++ b/modules/juce_gui_basics/keyboard/juce_KeyboardFocusTraverser.cpp @@ -47,7 +47,8 @@ namespace KeyboardFocusTraverserHelpers detail::FocusHelpers::NavigationDirection direction) { if (auto* comp = detail::FocusHelpers::navigateFocus (current, container, direction, - &Component::isKeyboardFocusContainer)) + &Component::isKeyboardFocusContainer, + FocusTraverser::SkipDisabledComponents::yes)) { if (isKeyboardFocusable (comp, container)) return comp; @@ -85,7 +86,8 @@ std::vector KeyboardFocusTraverser::getAllComponents (Component* par std::vector components; detail::FocusHelpers::findAllComponents (parentComponent, components, - &Component::isKeyboardFocusContainer); + &Component::isKeyboardFocusContainer, + FocusTraverser::SkipDisabledComponents::yes); auto removePredicate = [parentComponent] (const Component* comp) {