Use preceding label as accessibility title of controls under macOS

Under MSW, screen readers use the label preceding a control without its
own label, such as wxChoice or wxTextCtrl, as its name, so dialogs are
accessible without doing anything special. Under macOS this didn't
happen, and VoiceOver only read the labels as separate texts, so users
tabbing through a dialog didn't hear what the controls were for.

Now, when a native control without its own label is created right after
a wxStaticText, the label is set as its accessibility title element,
which VoiceOver reads as the name of the control. Notice that for most
controls the accessibility element is the cell and not the view itself,
so this uses NSAccessibilityUnignoredDescendant() to link the elements
actually used by VoiceOver, and uses the document view for the controls
inside a scroll view.

Closes #27060.
This commit is contained in:
Quin Gillespie
2026-09-27 21:28:25 +02:00
committed by Vadim Zeitlin
parent 787dcd2de0
commit 07590bec33
6 changed files with 37 additions and 0 deletions
+1
View File
@@ -169,6 +169,7 @@ public :
void SetFont(const wxFont & font) override;
void SetToolTip( wxToolTip* tooltip ) override;
void SetAccessibilityLabel(const wxString& label) override;
void SetAccessibilityTitleElement(wxWidgetImpl* title) override;
void InstallEventHandler( WXWidget control = nullptr ) override;
bool EnableTouchEvents(int eventsMask) override;
+1
View File
@@ -397,6 +397,7 @@ public :
virtual void SetToolTip(wxToolTip* WXUNUSED(tooltip)) { }
virtual void SetAccessibilityLabel(const wxString& WXUNUSED(label)) { }
virtual void SetAccessibilityTitleElement(wxWidgetImpl* WXUNUSED(title)) { }
// is the clicked event sent AFTER the state already changed, so no additional
// state changing logic is required from the outside
+2
View File
@@ -38,6 +38,8 @@ public:
virtual bool AcceptsFocus() const override { return false; }
virtual wxOSXWidgetImpl* GetLabelPeer() const override { return GetPeer(); }
protected :
virtual wxString WXGetVisibleLabel() const override;
+4
View File
@@ -280,6 +280,10 @@ public:
// the 'true' OS level control for this wxWindow
wxOSXWidgetImpl* GetPeer() const;
// the peer of this window if it is a label which can be used as the
// accessibility title of the next control, or nullptr otherwise
virtual wxOSXWidgetImpl* GetLabelPeer() const { return nullptr; }
// optimization to avoid creating a user pane in wxWindow::Create if we already know
// we will replace it with our own peer
void DontCreatePeer();
+18
View File
@@ -4042,6 +4042,24 @@ void wxWidgetCocoaImpl::SetAccessibilityLabel(const wxString& label)
[view setAccessibilityLabel:str];
}
void wxWidgetCocoaImpl::SetAccessibilityTitleElement(wxWidgetImpl* title)
{
// VoiceOver reads the view inside a scroll view, e.g. the text view of a
// multiline wxTextCtrl, and not the scroll view itself.
NSView* view = m_osxView;
if ( [view isKindOfClass:[NSScrollView class]] )
{
NSView* const documentView = [(NSScrollView*)view documentView];
if ( documentView )
view = documentView;
}
// For most controls the accessibility element is not the view itself but
// its cell, so link the elements actually used by VoiceOver.
[NSAccessibilityUnignoredDescendant(view) setAccessibilityTitleUIElement:
NSAccessibilityUnignoredDescendant(title->GetWXWidget())];
}
void wxWidgetCocoaImpl::InstallEventHandler( WXWidget control )
{
WXWidget c = control ? control : (WXWidget) m_osxView;
+11
View File
@@ -453,6 +453,17 @@ void wxWindowMac::MacPostControlCreate(const wxPoint& pos,
}
#endif
// Controls without their own label are typically preceded by a label
// describing them, which screen readers use as their name under MSW, so
// do the same here.
if ( GetLabel().empty() && !GetLabelPeer() )
{
const wxWindow* const prev = GetPrevSibling();
wxOSXWidgetImpl* const labelPeer = prev ? prev->GetLabelPeer() : nullptr;
if ( labelPeer )
GetPeer()->SetAccessibilityTitleElement(labelPeer);
}
}
void wxWindowMac::DoSetWindowVariant( wxWindowVariant variant )