Repository: nvaccess/nvda

Issue:
WAI best practice example for tree grid is not working on NVDA
### Steps to reproduce:
1. Go to https://www.w3.org/TR/wai-aria-practices/examples/treegrid/treegrid-1.html
2. Use linear navigation to get to TreeGrid (not tab navigation)

### Actual behavior:
Table not detected

### Expected behavior:
User should be able to interact with tree-grid using SR per navigation pattern: https://www.w3.org/TR/wai-aria-practices/examples/treegrid/treegrid-1.html.

Note that same page works with JAWS


Discussion:
@mertdeg2: Please completely fill out the bug report draft. Windows, NVDA, JAWS and all the different webbrowser versions are missing. And please expand the actual and the expected behavior with the naming of the mode, which you used in NVDA (browse or focus mode), as this isn't fully clear. Thanks.
Sorry Daniel, I am not used to creating fully clear bugs.. I am adding some more details (mostly pasting an internal bug details a coworker created) below.

OBSERVED NVDA RESULTS =======
- I have used NVDA browse mode. My NVDA version is 2018.4

- Windows + Chrome + NVDA: tree-grid in the table is not detected - screen reader will say no table found on the page. When I will navigate to the table NVDA will announce that it is focus on the table, but will not provide table size info. To navigate announce list of tables press T, https://webaim.org/resources/shortcuts/nvda#tables.

Note that this might be a Chrome regression. The original person who discovered the bug couldn't reproduce it in Safari and Firefox with NVDA, VoiceOver.
Chrome versions: 
Version 76.0.3782.0 (Official Build) canary (64-bit)
Version 74.0.3729.108 (Official Build) (64-bit)

OTHER OBSERVED RESULTS =====
- Mac (10.13.6 (17G6030)) + Safari + VoiceOver - table is detected and correct table sizing provided based on number of rows before expanded action.

- Windows 10 + FireFox 66.0.3 + JAWS2019 - Table with tree grid is not detected as well
Windows 10 + JAWS2019 +  Chrome - Table is detected, but doesn't give correct table sizing (adds a few more rows then actual size).

- Mac + VoiceOver + Chrome (Stable and Canary versions): table with tree grid is  announced as "table empty X columns, 0  rows". Blocked issue: http://b/131245652.
To repro on the page with table-treegrid press CTRL+Option+CMD + T.
 
@mertdeg2: Please try to reproduce this with [NVDA 2019.2 Beta build 17522](https://ci.appveyor.com/api/buildjobs/pksyej8s246fbl6g/artifacts/output/nvda_snapshot_beta-17522,a7bdcaea.exe). You don't have to install it, just run the Installer.
Yup just tried this. It acts the same way on the new version.

Specifically, this table is not navigable:

![image](https://user-images.githubusercontent.com/16231118/59384861-08548880-8d18-11e9-8e58-5ec0116f8083.png)

URL: https://www.w3.org/TR/wai-aria-practices/examples/treegrid/treegrid-1.html
@mertdeg2 from the data I have the issue repros in Firefox + NVDA as well.
I believe this should be addressed on the NVDA side.

cc: @jcsteh
This is not a browser issue. That said, fixing this is going to be messy. NVDA deliberately doesn't render list boxes and trees in browse mode because they can be very long, offer interaction patterns that don't make sense in browse mode (e.g. expanding and collapsing many levels), etc. I believe JAWS does the same. It's being argued here that treegrids should be different, which seems inconsistent and presents a couple of challenges:

1. UX: How would the user expand and collapse rows?
2. Technical: Given that both tree and treegrid map to the tree role in Windows, NVDA has to differentiate these. That can be done using xml-roles, but that's incredibly ugly. And if we're suggesting the UX should be so wildly different, that suggests the mapping is wrong.
Our tester said that the treegrid worked in the JAWS virtual buffer.
What about grids too?
> Our tester said that the treegrid worked in the JAWS virtual buffer.

I'm not doubting this. What I don't know is whether the UX is actually decent. For example, how are levels and expanded/collapsed reported, and how do you expand/collapse a row without unintentionally clicking a column?

> What about grids too?

Grids are much simpler: you can just expose them like a table. There's no hierarchy to worry about.
I don't have an opinion on the right UI is, but for now if NVDA wanted to allow traversal in browse mode, but not yet allow toggling of row expansion, it could do that since the treegrids are exposed as tables. The collapsed rows just wouldn't be available. 

Resolving patch:
diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py
--- a/source/NVDAObjects/IAccessible/ia2Web.py
+++ b/source/NVDAObjects/IAccessible/ia2Web.py
@@ -112,6 +112,10 @@ class BlockQuote(Ia2Web):
 	role = controlTypes.ROLE_BLOCKQUOTE
 
 
+class Treegrid(Ia2Web):
+	role = controlTypes.ROLE_TABLE
+
+
 class Article(Ia2Web):
 	role = controlTypes.ROLE_ARTICLE
 
@@ -222,6 +226,8 @@ def findExtraOverlayClasses(obj, clsList, baseClass=Ia2Web, documentClass=None):
 	xmlRoles = obj.IA2Attributes.get("xml-roles", "").split(" ")
 	if iaRole == IAccessibleHandler.IA2_ROLE_SECTION and obj.IA2Attributes.get("tag", None) == "blockquote":
 		clsList.append(BlockQuote)
+	elif iaRole == oleacc.ROLE_SYSTEM_OUTLINE and "treegrid" in xmlRoles:
+		clsList.append(Treegrid)
 	elif iaRole == oleacc.ROLE_SYSTEM_DOCUMENT and xmlRoles[0] == "article":
 		clsList.append(Article)
 	elif xmlRoles[0] == "region" and obj.name:
diff --git a/source/speech/__init__.py b/source/speech/__init__.py
--- a/source/speech/__init__.py
+++ b/source/speech/__init__.py
@@ -1910,6 +1910,18 @@ def getControlFieldSpeech(  # noqa: C901
 		out = []
 		if ariaCurrent:
 			out.extend(ariaCurrentSequence)
+		# Speak expanded / collapsed / level for treeview items (in ARIA treegrids)
+		if role == controlTypes.ROLE_TREEVIEWITEM:
+			if controlTypes.STATE_EXPANDED in states:
+				out.extend(
+					getPropertiesSpeech(reason=reason, states={controlTypes.STATE_EXPANDED}, _role=role)
+				)
+			elif controlTypes.STATE_COLLAPSED in states:
+				out.extend(
+					getPropertiesSpeech(reason=reason, states={controlTypes.STATE_COLLAPSED}, _role=role)
+				)
+			if levelSequence:
+				out.extend(levelSequence)
 		if role == controlTypes.ROLE_GRAPHIC and content:
 			out.append(content)
 		types.logBadSequenceTypes(out)
diff --git a/source/virtualBuffers/gecko_ia2.py b/source/virtualBuffers/gecko_ia2.py
--- a/source/virtualBuffers/gecko_ia2.py
+++ b/source/virtualBuffers/gecko_ia2.py
@@ -94,8 +94,7 @@ def _normalizeControlField(self,attrs):
 			# This is a named link destination, not a link which can be activated. The user doesn't care about these.
 			role=controlTypes.ROLE_TEXTFRAME
 		level=attrs.get('IAccessible2::attribute_level',"")
-
-		xmlRoles=attrs.get("IAccessible2::attribute_xml-roles", "").split(" ")
+		xmlRoles = attrs.get("IAccessible2::attribute_xml-roles", "").split(" ")
 		landmark = next((xr for xr in xmlRoles if xr in aria.landmarkRoles), None)
 		if landmark and role != controlTypes.ROLE_LANDMARK and landmark != xmlRoles[0]:
 			# Ignore the landmark role


Test patch:
diff --git a/tests/system/libraries/ChromeLib.py b/tests/system/libraries/ChromeLib.py
--- a/tests/system/libraries/ChromeLib.py
+++ b/tests/system/libraries/ChromeLib.py
@@ -125,6 +125,7 @@ def getSpeechAfterKey(key) -> str:
 		spy.wait_for_speech_to_finish()
 		nextSpeechIndex = spy.get_next_speech_index()
 		spy.emulateKeyPress(key)
+		spy.wait_for_speech_to_finish()
 		speech = spy.get_speech_at_index_until_now(nextSpeechIndex)
 		return speech
 
diff --git a/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py b/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py
--- a/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py
+++ b/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py
@@ -77,17 +77,6 @@ def _onNvdaSpeech(self, speechSequence=None):
 			self._lastSpeechTime_requiresLock = _timer()
 			self._nvdaSpeech_requiresLock.append(speechSequence)
 
-	@staticmethod
-	def _flattenCommandsSeparatingWithNewline(commandArray):
-		"""
-		Flatten many collections of speech sequences into a single speech sequence. Each original speech sequence
-		is separated by a newline string.
-		@param commandArray: is a collection of speechSequences
-		@return: speechSequence
-		"""
-		f = [c for commands in commandArray for newlineJoined in [commands, [u"\n"]] for c in newlineJoined]
-		return f
-
 	@staticmethod
 	def _getJoinedBaseStringsFromCommands(speechCommandArray) -> str:
 		baseStrings = [c for c in speechCommandArray if isinstance(c, str)]
@@ -103,11 +92,10 @@ def get_speech_at_index_until_now(self, speechIndex: int) -> str:
 		@return: The speech joined together, see L{_getJoinedBaseStringsFromCommands}
 		"""
 		with threading.Lock():
-			speechCommands = self._flattenCommandsSeparatingWithNewline(
-				self._nvdaSpeech_requiresLock[speechIndex:]
-			)
-			joined = self._getJoinedBaseStringsFromCommands(speechCommands)
-			return joined
+			speechCommands = [
+				self._getJoinedBaseStringsFromCommands(x) for x in self._nvdaSpeech_requiresLock[speechIndex:]
+			]
+			return "\n".join(x for x in speechCommands if x and not x.isspace())
 
 	def get_last_speech_index(self) -> int:
 		with threading.Lock():
diff --git a/tests/system/robot/chromeTests.py b/tests/system/robot/chromeTests.py
--- a/tests/system/robot/chromeTests.py
+++ b/tests/system/robot/chromeTests.py
@@ -6,6 +6,7 @@
 """Logic for NVDA + Google Chrome tests
 """
 
+import os
 from robot.libraries.BuiltIn import BuiltIn
 # imported methods start with underscore (_) so they don't get imported into robot files as keywords
 from SystemTestSpy import (
@@ -15,12 +16,18 @@
 # Imported for type information
 from ChromeLib import ChromeLib as _ChromeLib
 from AssertsLib import AssertsLib as _AssertsLib
+import NvdaLib as _NvdaLib
 
 _builtIn: BuiltIn = BuiltIn()
 _chrome: _ChromeLib = _getLib("ChromeLib")
 _asserts: _AssertsLib = _getLib("AssertsLib")
 
 
+ARIAExamplesDir = os.path.join(
+	_NvdaLib._locations.repoRoot, "include", "w3c-aria-practices", "examples"
+)
+
+
 def checkbox_labelled_by_inner_element():
 	_chrome.prepareChrome(
 		r"""
@@ -97,7 +104,7 @@ def announce_list_item_when_moving_by_word_or_character():
 	_asserts.strings_match(
 		actualSpeech,
 		"\n".join([
-			"list item  level 1  ",
+			"list item  level 1",
 			"b"
 		])
 	)
@@ -204,7 +211,7 @@ def test_pr11606():
 	_asserts.strings_match(
 		actualSpeech,
 		"\n".join([
-			"out of link  ",
+			"out of link",
 			"space"
 		])
 	)
@@ -222,3 +229,62 @@ def test_pr11606():
 		actualSpeech,
 		"bullet  link  A    link  B"
 	)
+
+
+def test_ariaTreeGrid_browseMode():
+	"""
+	Ensure that ARIA treegrids are accessible as a standard table in browse mode.
+	"""
+	testFile = os.path.join(ARIAExamplesDir, "treegrid", "treegrid-1.html")
+	_chrome.prepareChrome(
+		f"""
+			<iframe src="{testFile}" />
+		"""
+	)
+	# Jump to the first heading in the iframe.
+	actualSpeech = _chrome.getSpeechAfterKey("h")
+	_asserts.strings_match(
+		actualSpeech,
+		"frame  main landmark  Treegrid Email Inbox Example  heading  level 1"
+	)
+	# Tab to the first link.
+	# This ensures that focus is totally within the iframe
+	# so as to not cause focus to hit the iframe's document
+	# when entering focus mode on the treegrid later.
+	actualSpeech = _chrome.getSpeechAfterKey("tab")
+	_asserts.strings_match(
+		actualSpeech,
+		"issue 790.  link"
+	)
+	# Jump to the ARIA treegrid with the next table quicknav command.
+	# The browse mode caret will be inside the table on the caption before the first row.
+	actualSpeech = _chrome.getSpeechAfterKey("t")
+	_asserts.strings_match(
+		actualSpeech,
+		"Inbox  table  clickable  with 5 rows and 3 columns  Inbox"
+	)
+	# Move past the caption onto row 1 with downArrow
+	actualSpeech = _chrome.getSpeechAfterKey("downArrow")
+	_asserts.strings_match(
+		actualSpeech,
+		"row 1  Subject  column 1  Subject"
+	)
+	# Navigate to row 2 column 1 with NVDA table navigation command
+	actualSpeech = _chrome.getSpeechAfterKey("control+alt+downArrow")
+	_asserts.strings_match(
+		actualSpeech,
+		"expanded  level 1  row 2  Treegrids are awesome"
+	)
+	# Press enter to activate NVDA focus mode and focus the current row
+	actualSpeech = _chrome.getSpeechAfterKey("enter")
+	_asserts.strings_match(
+		actualSpeech,
+		"\n".join([
+			# focus mode turns on
+			"Focus mode",
+			# Focus enters the ARIA treegrid (table)
+			"Inbox  table",
+			# Focus lands on row 2
+			"level 1  Treegrids are awesome Want to learn how to use them? aaron at thegoogle dot rocks  expanded",
+		])
+	)
diff --git a/tests/system/robot/chromeTests.robot b/tests/system/robot/chromeTests.robot
--- a/tests/system/robot/chromeTests.robot
+++ b/tests/system/robot/chromeTests.robot
@@ -35,3 +35,6 @@ i7562
 pr11606
 	[Documentation]	Announce the correct line when placed at the end of a link at the end of a list item in a contenteditable
 	test_pr11606
+ARIA treegrid
+	[Documentation]	Ensure that ARIA treegrids are accessible as a standard table in browse mode.
+	test_ariaTreeGrid_browseMode
\ No newline at end of file
diff --git a/tests/system/robot/startupShutdownNVDA.py b/tests/system/robot/startupShutdownNVDA.py
--- a/tests/system/robot/startupShutdownNVDA.py
+++ b/tests/system/robot/startupShutdownNVDA.py
@@ -48,8 +48,10 @@ def quits_from_keyboard():
 
 	_asserts.strings_match(
 		actualSpeech,
-		"Exit NVDA  dialog  \n"
-		"What would you like to do?  combo box  Exit  collapsed  Alt plus d"
+		"\n".join([
+			"Exit NVDA  dialog",
+			"What would you like to do?  combo box  Exit  collapsed  Alt plus d"
+		])
 	)
 	_builtIn.sleep(1)  # the dialog is not always receiving the enter keypress, wait a little longer for it
 	spy.emulateKeyPress("enter", blockUntilProcessed=False)
@@ -65,13 +67,17 @@ def read_welcome_dialog():
 
 	_asserts.strings_match(
 		actualSpeech,
-		"Welcome to NVDA  dialog  Welcome to NVDA! Most commands for controlling NVDA require you to hold "
-		"down the NVDA key while pressing other keys. By default, the numpad Insert and main Insert keys "
-		"may both be used as the NVDA key. You can also configure NVDA to use the Caps Lock as the NVDA "
-		"key. Press NVDA plus n at any time to activate the NVDA menu. From this menu, you can configure "
-		"NVDA, get help and access other NVDA functions.  \n"
-		"Options  grouping  \n"
-		"Keyboard layout:  combo box  desktop  collapsed  Alt plus k"
+		"\n".join([
+			(
+				"Welcome to NVDA  dialog  Welcome to NVDA! Most commands for controlling NVDA require you to hold "
+				"down the NVDA key while pressing other keys. By default, the numpad Insert and main Insert keys "
+				"may both be used as the NVDA key. You can also configure NVDA to use the Caps Lock as the NVDA "
+				"key. Press NVDA plus n at any time to activate the NVDA menu. From this menu, you can configure "
+				"NVDA, get help and access other NVDA functions."
+			),
+			"Options  grouping",
+			"Keyboard layout:  combo box  desktop  collapsed  Alt plus k"
+		])
 	)
 	_builtIn.sleep(1)  # the dialog is not always receiving the enter keypress, wait a little longer for it
 	spy.emulateKeyPress("enter")