diff --git a/source/UIAHandler/browseMode.py b/source/UIAHandler/browseMode.py index 6be5c19201a..44d33255e0c 100644 --- a/source/UIAHandler/browseMode.py +++ b/source/UIAHandler/browseMode.py @@ -692,6 +692,19 @@ def _iterNodesByType(self, nodeType, direction="next", pos=None): UIAHandler.UIA_SliderControlTypeId, ) return UIAControlQuicknavIterator(nodeType, self, pos, condition, direction) + elif nodeType == "clickable": + # Match the generic control types which UIAWeb exposes with State.CLICKABLE. + condition = createUIAMultiPropertyCondition( + { + UIAHandler.UIA_ControlTypePropertyId: [ + UIAHandler.UIA_TextControlTypeId, + UIAHandler.UIA_GroupControlTypeId, + UIAHandler.UIA_ImageControlTypeId, + ], + UIAHandler.UIA_IsInvokePatternAvailablePropertyId: True, + }, + ) + return UIAControlQuicknavIterator(nodeType, self, pos, condition, direction) elif nodeType == "nonTextContainer": condition = createUIAMultiPropertyCondition( { diff --git a/source/browseMode.py b/source/browseMode.py index 1d7714bbc06..efda0e8767c 100644 --- a/source/browseMode.py +++ b/source/browseMode.py @@ -1234,6 +1234,21 @@ def script_prevSelectedElement(self, gesture: inputCore.InputGesture) -> None: # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. touchLabel=_("sliders"), ) +qn( + "clickable", + key=None, + # Translators: Input help message for a quick navigation command in browse mode. + nextDoc=_("moves to the next clickable element"), + # Translators: Message presented when the browse mode element is not found. + nextError=_("no next clickable element"), + # Translators: Input help message for a quick navigation command in browse mode. + prevDoc=_("moves to the previous clickable element"), + # Translators: Message presented when the browse mode element is not found. + prevError=_("no previous clickable element"), + readUnit=textInfos.UNIT_LINE, + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("clickable elements"), +) qn( "article", key=None, diff --git a/source/virtualBuffers/MSHTML.py b/source/virtualBuffers/MSHTML.py index b754815b384..eda0e83608f 100644 --- a/source/virtualBuffers/MSHTML.py +++ b/source/virtualBuffers/MSHTML.py @@ -401,6 +401,12 @@ def _searchableAttribsForNodeType(self, nodeType): {"IAccessible::role": [oleacc.ROLE_SYSTEM_SLIDER]}, {"HTMLAttrib::role": ["slider"]}, ] + elif nodeType == "clickable": + attrs = [ + {"HTMLAttrib::onclick": [VBufStorage_findMatch_notEmpty]}, + {"HTMLAttrib::onmousedown": [VBufStorage_findMatch_notEmpty]}, + {"HTMLAttrib::onmouseup": [VBufStorage_findMatch_notEmpty]}, + ] elif nodeType == "table": attrs = {"IHTMLDOMNode::nodeName": ["TABLE"]} if not config.conf["documentFormatting"]["includeLayoutTables"]: diff --git a/source/virtualBuffers/gecko_ia2.py b/source/virtualBuffers/gecko_ia2.py index 4b9953ba6c6..7ee0ba6fa5b 100755 --- a/source/virtualBuffers/gecko_ia2.py +++ b/source/virtualBuffers/gecko_ia2.py @@ -488,6 +488,8 @@ def _searchableAttribsForNodeType(self, nodeType): {"IAccessible::role": [oleacc.ROLE_SYSTEM_SLIDER]}, {"IAccessible2::attribute_xml-roles": [VBufStorage_findMatch_word("slider")]}, ] + elif nodeType == "clickable": + attrs = {"IAccessibleAction_click": [VBufStorage_findMatch_notEmpty]} elif nodeType == "graphic": attrs = {"IAccessible::role": [oleacc.ROLE_SYSTEM_GRAPHIC]} elif nodeType == "blockQuote": diff --git a/tests/system/robot/chromeTests.py b/tests/system/robot/chromeTests.py index 81a67d56a98..527218ed569 100644 --- a/tests/system/robot/chromeTests.py +++ b/tests/system/robot/chromeTests.py @@ -2760,6 +2760,44 @@ def test_styleNav(): _asserts.strings_match(actualSpeech, "No next same style text") +def test_clickableNavigation() -> None: + """Tests that unassigned quick navigation commands move between clickable elements.""" + spy: "NVDASpyLib" = _NvdaLib.getSpyLib() + spy.assignGesture( + "kb:z", + "browseMode", + "BrowseModeTreeInterceptor", + "nextClickable", + ) + spy.assignGesture( + "kb:shift+z", + "browseMode", + "BrowseModeTreeInterceptor", + "previousClickable", + ) + # The navigation must use the clickable metadata even when its speech reporting is disabled. + spy.set_configValue(["documentFormatting", "reportClickable"], False) + _chrome.prepareChrome(""" +
Before the custom controls
+ +Between the custom controls
+After the custom controls
+ """) + + actualSpeech = _chrome.getSpeechAfterKey("z") + _asserts.strings_match(actualSpeech, "First custom control") + actualSpeech = _chrome.getSpeechAfterKey("z") + _asserts.strings_match(actualSpeech, "Second custom control") + actualSpeech = _chrome.getSpeechAfterKey("z") + _asserts.strings_match(actualSpeech, "no next clickable element") + actualSpeech = _chrome.getSpeechAfterKey("shift+z") + _asserts.strings_match(actualSpeech, "First custom control") + actualSpeech = _chrome.getSpeechAfterKey("shift+z") + _asserts.strings_match(actualSpeech, "no previous clickable element") + + def test_ariaErrorMessage(): _chrome.prepareChrome("""