Repository: nvaccess/nvda

Issue:
aria-expanded status not announced when ::before element has content
I'm working on a simple dropdown implementation. Using JavaScript, an `aria-expanded` attribute is toggled between `false` and `true`, and its specific content's visibility is toggled then.

I'm used to NVDA announcing a change of `aria-expanded` very reliably when the focus is on the specific link or button.

Demo: https://codepen.io/accessibility-developer-guide/pen/pKoJbK

But I noticed that when this link or button has some content with `::before { content: '...' }`, the change is not announced anymore.

Demo: https://codepen.io/accessibility-developer-guide/pen/VdwvwW

I wanted to display a `+` or `-` sign in front of the dropdown toggler to indicate its status visually. But this doesn't seem to work with `content: '...'`.

Is this expected behaviour? Or a bug?


Discussion:
It looks like in the second example, the node with focus (the button) is being completely replaced each time it is expanded or collapsed. Thus, to NVDA   the expanded/collapsed state is not just changing, but rather  the focus in its entireity is changing.
Are you creating a new node here? or is this some kind of Browser defect that is causing the node to be recreated?  
To see that the node is being recreated/refocused from NVDA's point of view, tab to one of the buttons, switch to focus mode with NVDA+space, and then press space to expand/collapse the button. NVDA will announce the button in its entireity just like a new focus. Rather than in the first example where it just announces the change of expanded / collapsed.
@jcsteh: possibly another real-world testcase where Firefox is re-creating an Accessible element because of a change in CSS.
Correct. [Mozilla bug 395900](https://bugzilla.mozilla.org/show_bug.cgi?id=395900).
Thanks for sorting this out, guys.

Resolving patch:
diff --git a/source/browseMode.py b/source/browseMode.py
--- a/source/browseMode.py
+++ b/source/browseMode.py
@@ -1230,8 +1230,23 @@ def _set_selection(self, info, reason=controlTypes.REASON_CARET):
 				return
 			if obj==self.rootNVDAObject:
 				return
-			if focusObj and not eventHandler.isPendingEvents("gainFocus") and focusObj!=self.rootNVDAObject and focusObj != api.getFocusObject() and self._shouldSetFocusToObj(focusObj):
-				focusObj.setFocus()
+			# Set focus to this object, if
+			if (
+				# We actually found an object to set focus to.
+				focusObj
+				# And there are no future focus events already queued:
+				# If there were then setting focus here would be a waste of time as the focus would be replaced anyway.
+				and not eventHandler.isPendingEvents("gainFocus")
+				# And this object is not the root of the document:
+				# Setting focus to the document itself is not what the user would ever expect.
+				and focusObj!=self.rootNVDAObject 
+				# This object does not already have focus
+				and focusObj != api.getFocusObject() 
+				# And Only if this browseMode implementation actually wants to set focus to this kind of object:
+				# For instance, we may wish to set focus to a button, but not a div (if it does not have a tabindex).
+				and self._shouldSetFocusToObj(focusObj)
+			):
+				self.setFocusToObj(focusObj)
 			obj.scrollIntoView()
 			if self.programmaticScrollMayFireEvent:
 				self._lastProgrammaticScrollTime = time.time()
@@ -1239,6 +1254,13 @@ def _set_selection(self, info, reason=controlTypes.REASON_CARET):
 		# Queue the reporting of pass through mode so that it will be spoken after the actual content.
 		queueHandler.queueFunction(queueHandler.eventQueue, reportPassThrough, self)
 
+	def setFocusToObj(self,obj):
+		"""
+		Sets focus to the given object in this browseMode document.
+		"""
+		self._lastFocusObj=obj
+		obj.setFocus()
+
 	def _shouldSetFocusToObj(self, obj):
 		"""Determine whether an object should receive focus.
 		Subclasses may extend or override this method.
@@ -1393,6 +1415,7 @@ def _replayFocusEnteredEvents(self):
 				log.exception("Error executing focusEntered event: %s" % parent)
 
 	def event_gainFocus(self, obj, nextHandler):
+		lastFocusObj=self._lastFocusObj
 		enteringFromOutside=self._enteringFromOutside
 		self._enteringFromOutside=False
 		if not self.isReady:
@@ -1436,12 +1459,23 @@ def event_gainFocus(self, obj, nextHandler):
 				self._replayFocusEnteredEvents()
 			return nextHandler()
 
+		try:
+			lastFocusInfo=self.makeTextInfo(lastFocusObj) if lastFocusObj else None
+		except LookupError:
+			lastFocusInfo=None
 		#We only want to update the caret and speak the field if we're not in the same one as before
-		caretInfo=self.makeTextInfo(textInfos.POSITION_CARET)
-		# Expand to one character, as isOverlapping() doesn't treat, for example, (4,4) and (4,5) as overlapping.
-		caretInfo.expand(textInfos.UNIT_CHARACTER)
-		if not self._hadFirstGainFocus or not focusInfo.isOverlapping(caretInfo):
-			# The virtual caret is not within the focus node.
+		if (
+			# We always want to report and update if this is the very first focus event.
+			not self._hadFirstGainFocus
+			# We should alrways report and update if the last focus no longer exists (I.e. it no longer has a position in the document):
+			or not lastFocusInfo 
+			# We should report and update if The new focus starts at a different position to the last focus
+			or focusInfo.compareEndPoints(lastFocusInfo,"startToStart")!=0
+			# We should report and update if the new focus ends after the last focus:
+			# It is either at an entirely different position, or it is wider.
+			or focusInfo.compareEndPoints(lastFocusInfo,"endToEnd")>0
+		):
+			# The newly focused node is not due to the caret moving.
 			oldPassThrough=self.passThrough
 			passThrough=self.shouldPassThrough(obj,reason=controlTypes.REASON_FOCUS)
 			if not oldPassThrough and (passThrough or sayAllHandler.isRunning()):


Test patch: