Skip to content

Commit

Permalink
Add support for aria placeholder (PR #7055)
Browse files Browse the repository at this point in the history
Fixes #7004

Aria-placeholder now supported in Chrome, Firefox, Edge and IE. Placeholder is reported unless the field has some content.
  • Loading branch information
feerrenrut committed Jun 21, 2017
1 parent bc94508 commit c8a7c00
Show file tree
Hide file tree
Showing 10 changed files with 170 additions and 6 deletions.
1 change: 1 addition & 0 deletions nvdaHelper/vbufBackends/mshtml/mshtml.cpp
Expand Up @@ -490,6 +490,7 @@ inline void getAttributesFromHTMLDOMNode(IHTMLDOMNode* pHTMLDOMNode,wstring& nod
macro_addHTMLAttributeToMap(L"aria-busy",false,pHTMLAttributeCollection2,attribsMap,tempVar,tempAttribNode);
macro_addHTMLAttributeToMap(L"aria-atomic",false,pHTMLAttributeCollection2,attribsMap,tempVar,tempAttribNode);
macro_addHTMLAttributeToMap(L"aria-current",false,pHTMLAttributeCollection2,attribsMap,tempVar,tempAttribNode);
macro_addHTMLAttributeToMap(L"aria-placeholder",false,pHTMLAttributeCollection2,attribsMap,tempVar,tempAttribNode);
pHTMLAttributeCollection2->Release();
}

Expand Down
3 changes: 3 additions & 0 deletions source/NVDAObjects/IAccessible/MSHTML.py
Expand Up @@ -523,6 +523,9 @@ def _get_isCurrent(self):
def _get_HTMLAttributes(self):
return HTMLAttribCache(self.HTMLNode)

def _get_placeholder(self):
return self.HTMLAttributes["aria-placeholder"]

def __init__(self,HTMLNode=None,IAccessibleObject=None,IAccessibleChildID=None,**kwargs):
self.HTMLNodeHasAncestorIAccessible=False
if not IAccessibleObject:
Expand Down
4 changes: 4 additions & 0 deletions source/NVDAObjects/IAccessible/ia2Web.py
Expand Up @@ -33,6 +33,10 @@ def _get_isCurrent(self):
current = self.IA2Attributes.get("current", False)
return current

def _get_placeholder(self):
placeholder = self.IA2Attributes.get('placeholder', None)
return placeholder

class Document(Ia2Web):
value = None

Expand Down
65 changes: 65 additions & 0 deletions source/NVDAObjects/UIA/edge.py
Expand Up @@ -21,6 +21,43 @@
from UIAUtils import *
from . import UIA, UIATextInfo

def splitUIAElementAttribs(attribsString):
"""Split an UIA Element attributes string into a dict of attribute keys and values.
An invalid attributes string does not cause an error, but strange results may be returned.
@param attribsString: The UIA Element attributes string to convert.
@type attribsString: str
@return: A dict of the attribute keys and values, where values are strings
@rtype: {str: str}
"""
attribsDict = {}
tmp = ""
key = ""
inEscape = False
for char in attribsString:
if inEscape:
tmp += char
inEscape = False
elif char == "\\":
inEscape = True
elif char == "=":
# We're about to move on to the value, so save the key and clear tmp.
key = tmp
tmp = ""
elif char == ";":
# We're about to move on to a new attribute.
if key:
# Add this key/value pair to the dict.
attribsDict[key] = tmp
key = ""
tmp = ""
else:
tmp += char
# If there was no trailing semi-colon, we need to handle the last attribute.
if key:
# Add this key/value pair to the dict.
attribsDict[key] = tmp
return attribsDict

class EdgeTextInfo(UIATextInfo):

def _get_UIAElementAtStartWithReplacedContent(self):
Expand Down Expand Up @@ -102,6 +139,8 @@ def _getControlFieldForObject(self,obj,isEmbedded=False,startOfNode=False,endOfN
field['states'].add(controlTypes.STATE_EDITABLE)
# report if the field is 'current'
field['current']=obj.isCurrent
if obj.placeholder and obj._isTextEmpty:
field['placeholder']=obj.placeholder
# For certain controls, if ARIA overrides the label, then force the field's content (value) to the label
# Later processing in Edge's getTextWithFields will remove descendant content from fields with a content attribute.
ariaProperties=obj._getUIACacheablePropertyValue(UIAHandler.UIA_AriaPropertiesPropertyId)
Expand Down Expand Up @@ -409,6 +448,9 @@ def _get_description(self):
pass
return super(EdgeNode,self).description

def _get_ariaProperties(self):
return splitUIAElementAttribs(self.UIAElement.currentAriaProperties)

# RegEx to get the value for the aria-current property. This will be looking for a the value of 'current'
# in a list of strings like "something=true;current=date;". We want to capture one group, after the '='
# character and before the ';' character.
Expand All @@ -425,6 +467,29 @@ def _get_isCurrent(self):
return valueOfAriaCurrent
return False

def _get_placeholder(self):
ariaPlaceholder = self.ariaProperties.get('placeholder', None)
return ariaPlaceholder

def _get__isTextEmpty(self):
# NOTE: we can not check the result of the EdgeTextInfo move implementation to determine if we added
# any characters to the range, since it seems to return 1 even when the text property has not changed.
# Also we can not move (repeatedly by one character) since this can overrun the end of the field in edge.
# So instead, we use self to make a text info (which should have the right range) and then use the UIA
# specific _rangeObj.getText function to get a subset of the full range of characters.
ti = self.makeTextInfo(self)
if ti.isCollapsed:
# it is collapsed therefore it is empty.
# exit early so we do not have to do not have to fetch `ti.text` which
# is potentially costly to performance.
return True
numberOfCharacters = 2
text = ti._rangeObj.getText(numberOfCharacters)
# Edge can report newline for empty fields:
if text == "\n":
return True
return False

class EdgeList(EdgeNode):

# non-focusable lists are readonly lists (ensures correct NVDA presentation category)
Expand Down
17 changes: 17 additions & 0 deletions source/NVDAObjects/__init__.py
Expand Up @@ -818,6 +818,15 @@ def reportFocus(self):
"""
speech.speakObject(self,reason=controlTypes.REASON_FOCUS)

def _get_placeholder(self):
"""If it exists for this object get the value of the placeholder text.
For example this might be the aria-placeholder text for a field in a web page.
@return: the placeholder text else None
@rtype: String or None
"""
log.debug("Potential unimplemented child class: %r" %self)
return None

def _reportErrorInPreviousWord(self):
try:
# self might be a descendant of the text control; e.g. Symphony.
Expand Down Expand Up @@ -972,6 +981,14 @@ def _get_basicText(self):
def makeTextInfo(self,position):
return self.TextInfo(self,position)

def _get__isTextEmpty(self):
"""
@return C{True} if the text contained in the object is considered empty by the underlying implementation. In most cases this will match {isCollapsed}, however some implementations may consider a single space or line feed as an empty range.
"""
ti = self.makeTextInfo(textInfos.POSITION_FIRST)
ti.move(textInfos.UNIT_CHARACTER, 1, endPoint="end")
return ti.isCollapsed

@staticmethod
def _formatLongDevInfoString(string, truncateLen=250):
"""Format a potentially long string value for inclusion in devInfo.
Expand Down
15 changes: 13 additions & 2 deletions source/braille.py
Expand Up @@ -661,6 +661,9 @@ def getBrailleTextForProperties(**propertyValues):
except KeyError:
log.debugWarning("Aria-current value not handled: %s"%current)
textList.append(controlTypes.isCurrentLabels[True])
placeholder = propertyValues.get('placeholder', None)
if placeholder:
textList.append(placeholder)
if includeTableCellCoords and cellCoordsText:
textList.append(cellCoordsText)
return TEXT_SEPARATOR.join([x for x in textList if x])
Expand All @@ -686,7 +689,14 @@ def update(self):
obj = self.obj
presConfig = config.conf["presentation"]
role = obj.role
text = getBrailleTextForProperties(name=obj.name, role=role, roleText=obj.roleText, current=obj.isCurrent,
placeholderValue = obj.placeholder
if placeholderValue and not obj._isTextEmpty:
placeholderValue = None
text = getBrailleTextForProperties(
name=obj.name,
role=role,
current=obj.isCurrent,
placeholder=placeholderValue,
value=obj.value if not NVDAObjectHasUsefulText(obj) else None ,
states=obj.states,
description=obj.description if presConfig["reportObjectDescriptions"] else None,
Expand Down Expand Up @@ -730,6 +740,7 @@ def getControlFieldBraille(info, field, ancestors, reportStart, formatConfig):
states = field.get("states", set())
value=field.get('value',None)
current=field.get('current', None)
placeholder=field.get('placeholder', None)

if presCat == field.PRESCAT_LAYOUT:
text = []
Expand Down Expand Up @@ -760,7 +771,7 @@ def getControlFieldBraille(info, field, ancestors, reportStart, formatConfig):
# Don't report the role for math here.
# However, we still need to pass it (hence "_role").
"_role" if role == controlTypes.ROLE_MATH else "role": role,
"states": states,"value":value, "current":current}
"states": states,"value":value, "current":current, "placeholder":placeholder}
if config.conf["presentation"]["reportKeyboardShortcuts"]:
kbShortcut = field.get("keyboardShortcut")
if kbShortcut:
Expand Down
27 changes: 26 additions & 1 deletion source/speech.py
Expand Up @@ -305,11 +305,24 @@ def speakObjectProperties(obj,reason=controlTypes.REASON_QUERY,index=None,**allo
except NotImplementedError:
pass
newPropertyValues['current']=obj.isCurrent
if allowedProperties.get('placeholder', False):
newPropertyValues['placeholder']=obj.placeholder
#Get the speech text for the properties we want to speak, and then speak it
text=getSpeechTextForProperties(reason,**newPropertyValues)
if text:
speakText(text,index=index)

def _speakPlaceholderIfEmpty(info, obj, reason):
""" attempt to speak placeholder attribute if the textInfo 'info' is empty
@return: True if info was considered empty, and we attempted to speak the placeholder value.
False if info was not considered empty.
"""
textEmpty = obj._isTextEmpty
if textEmpty:
speakObjectProperties(obj,reason=reason,placeholder=True)
return True
return False

def speakObject(obj,reason=controlTypes.REASON_QUERY,index=None):
from NVDAObjects import NVDAObjectTextInfo
role=obj.role
Expand Down Expand Up @@ -357,14 +370,17 @@ def speakObject(obj,reason=controlTypes.REASON_QUERY,index=None):
try:
info=obj.makeTextInfo(textInfos.POSITION_SELECTION)
if not info.isCollapsed:
# if there is selected text, then there is a value and we do not report placeholder
# Translators: This is spoken to indicate what has been selected. for example 'selected hello world'
speakSelectionMessage(_("selected %s"),info.text)
else:
info.expand(textInfos.UNIT_LINE)
_speakPlaceholderIfEmpty(info, obj, reason)
speakTextInfo(info,unit=textInfos.UNIT_LINE,reason=controlTypes.REASON_CARET)
except:
newInfo=obj.makeTextInfo(textInfos.POSITION_ALL)
speakTextInfo(newInfo,unit=textInfos.UNIT_PARAGRAPH,reason=controlTypes.REASON_CARET)
if not _speakPlaceholderIfEmpty(newInfo, obj, reason):
speakTextInfo(newInfo,unit=textInfos.UNIT_PARAGRAPH,reason=controlTypes.REASON_CARET)
elif role==controlTypes.ROLE_MATH:
import mathPres
mathPres.ensureInit()
Expand Down Expand Up @@ -1007,6 +1023,9 @@ def getSpeechTextForProperties(reason=controlTypes.REASON_QUERY,**propertyValues
except KeyError:
log.debugWarning("Aria-current value not handled: %s"%ariaCurrent)
textList.append(controlTypes.isCurrentLabels[True])
placeholder = propertyValues.get('placeholder', None)
if placeholder:
textList.append(placeholder)
indexInGroup=propertyValues.get('positionInfo_indexInGroup',0)
similarItemsInGroup=propertyValues.get('positionInfo_similarItemsInGroup',0)
if 0<indexInGroup<=similarItemsInGroup:
Expand Down Expand Up @@ -1042,6 +1061,7 @@ def getControlFieldSpeech(attrs,ancestorAttrs,fieldType,formatConfig=None,extraD
states=attrs.get('states',set())
keyboardShortcut=attrs.get('keyboardShortcut', "")
ariaCurrent=attrs.get('current', None)
placeholderValue=attrs.get('placeholder', None)
value=attrs.get('value',"")
if reason==controlTypes.REASON_FOCUS or attrs.get('alwaysReportDescription',False):
description=attrs.get('description',"")
Expand All @@ -1058,6 +1078,7 @@ def getControlFieldSpeech(attrs,ancestorAttrs,fieldType,formatConfig=None,extraD
stateText=getSpeechTextForProperties(reason=reason,states=states,_role=role)
keyboardShortcutText=getSpeechTextForProperties(reason=reason,keyboardShortcut=keyboardShortcut) if config.conf["presentation"]["reportKeyboardShortcuts"] else ""
ariaCurrentText=getSpeechTextForProperties(reason=reason,current=ariaCurrent)
placeholderText=getSpeechTextForProperties(reason=reason,placeholder=placeholderValue)
nameText=getSpeechTextForProperties(reason=reason,name=name)
valueText=getSpeechTextForProperties(reason=reason,value=value)
descriptionText=(getSpeechTextForProperties(reason=reason,description=description)
Expand Down Expand Up @@ -1121,6 +1142,10 @@ def getControlFieldSpeech(attrs,ancestorAttrs,fieldType,formatConfig=None,extraD
content = attrs.get("content")
if content and speakContentFirst:
out.append(content)
if placeholderValue:
if valueText:
log.error("valueText exists when expected none: valueText:'%s' placeholderText:'%s'"%(valueText,placeholderText))
valueText = placeholderText
out.extend(x for x in (nameText,(stateText if speakStatesFirst else roleText),(roleText if speakStatesFirst else stateText),ariaCurrentText,valueText,descriptionText,levelText,keyboardShortcutText) if x)
if content and not speakContentFirst:
out.append(content)
Expand Down
3 changes: 3 additions & 0 deletions source/virtualBuffers/MSHTML.py
Expand Up @@ -53,6 +53,9 @@ def _normalizeControlField(self,attrs):
ariaCurrent = attrs.get('HTMLAttrib::aria-current', None)
if ariaCurrent is not None:
attrs['current']=ariaCurrent
placeholder = self._getPlaceholderAttribute(attrs, 'HTMLAttrib::aria-placeholder')
if placeholder:
attrs['placeholder']=placeholder
accRole=attrs.get('IAccessible::role',0)
accRole=int(accRole) if isinstance(accRole,basestring) and accRole.isdigit() else accRole
nodeName=attrs.get('IHTMLDOMNode::nodeName',"")
Expand Down
32 changes: 32 additions & 0 deletions source/virtualBuffers/__init__.py
Expand Up @@ -218,6 +218,38 @@ def _getTextRange(self,start,end):
return u""
return NVDAHelper.VBuf_getTextInRange(self.obj.VBufHandle,start,end,False) or u""

def _getPlaceholderAttribute(self, attrs, placeholderAttrsKey):
"""Gets the placeholder attribute to be used.
@return: The placeholder attribute when there is no content within the ControlField.
None when the ControlField has content.
@note: The content is considered empty if it holds a single space.
"""
placeholder = attrs.get(placeholderAttrsKey)
# For efficiency, only check if it is valid to return placeholder when we have a placeholder value to return.
if not placeholder:
return None
# Get the start and end offsets for the field. This can be used to check if the field has any content.
try:
start, end = self._getOffsetsFromFieldIdentifier(
int(attrs.get('controlIdentifier_docHandle')),
int(attrs.get('controlIdentifier_ID')))
except (LookupError, ValueError):
log.debugWarning("unable to get offsets used to fetch content")
return placeholder
else:
valueLen = end - start
if not valueLen: # value is empty, use placeholder
return placeholder
# Because fetching the content of the field could result in a large amount of text
# we only do it in order to check for space.
# We first compare the length by comparing the offsets, if the length is less than 2 (ie
# could hold space)
if valueLen < 2:
controlFieldText = self.obj.makeTextInfo(textInfos.offsets.Offsets(start, end)).text
if not controlFieldText or controlFieldText == ' ':
return placeholder
return None

def getTextWithFields(self,formatConfig=None):
start=self._startOffset
end=self._endOffset
Expand Down
9 changes: 6 additions & 3 deletions source/virtualBuffers/gecko_ia2.py
Expand Up @@ -23,9 +23,12 @@
class Gecko_ia2_TextInfo(VirtualBufferTextInfo):

def _normalizeControlField(self,attrs):
ariaCurrent = attrs.get("IAccessible2::attribute_current")
if ariaCurrent != None:
attrs['current']= ariaCurrent
current = attrs.get("IAccessible2::attribute_current")
if current is not None:
attrs['current']= current
placeholder = self._getPlaceholderAttribute(attrs, "IAccessible2::attribute_placeholder")
if placeholder is not None:
attrs['placeholder']= placeholder
accRole=attrs['IAccessible::role']
accRole=int(accRole) if accRole.isdigit() else accRole
role=IAccessibleHandler.IAccessibleRolesToNVDARoles.get(accRole,controlTypes.ROLE_UNKNOWN)
Expand Down

0 comments on commit c8a7c00

Please sign in to comment.