| /* |
| * Copyright (C) 2012 Google Inc. All rights reserved. |
| * |
| * Redistribution and use in source and binary forms, with or without |
| * modification, are permitted provided that the following conditions |
| * are met: |
| * |
| * 1. Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * 2. Redistributions in binary form must reproduce the above copyright |
| * notice, this list of conditions and the following disclaimer in the |
| * documentation and/or other materials provided with the distribution. |
| * 3. Neither the name of Apple Inc. ("Apple") nor the names of |
| * its contributors may be used to endorse or promote products derived |
| * from this software without specific prior written permission. |
| * |
| * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY |
| * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED |
| * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
| * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY |
| * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES |
| * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; |
| * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND |
| * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF |
| * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| |
| #include "config.h" |
| #include "AccessibilityNodeObject.h" |
| |
| #include "AXLogger.h" |
| #include "AXObjectCache.h" |
| #include "AccessibilityImageMapLink.h" |
| #include "AccessibilityLabel.h" |
| #include "AccessibilityList.h" |
| #include "AccessibilityListBox.h" |
| #include "AccessibilitySpinButton.h" |
| #include "AccessibilityTable.h" |
| #include "ComposedTreeIterator.h" |
| #include "ContainerNodeInlines.h" |
| #include "DateComponents.h" |
| #include "EditingInlines.h" |
| #include "ElementAncestorIteratorInlines.h" |
| #include "ElementChildIteratorInlines.h" |
| #include "Event.h" |
| #include "EventHandler.h" |
| #include "EventNames.h" |
| #include "FloatRect.h" |
| #include "FrameLoader.h" |
| #include "FrameSelection.h" |
| #include "HTMLAudioElement.h" |
| #include "HTMLButtonElement.h" |
| #include "HTMLCanvasElement.h" |
| #include "HTMLDetailsElement.h" |
| #include "HTMLFieldSetElement.h" |
| #include "HTMLFormElement.h" |
| #include "HTMLHtmlElement.h" |
| #include "HTMLImageElement.h" |
| #include "HTMLInputElement.h" |
| #include "HTMLLabelElement.h" |
| #include "HTMLLegendElement.h" |
| #include "HTMLNames.h" |
| #include "HTMLOptionElement.h" |
| #include "HTMLParagraphElement.h" |
| #include "HTMLParserIdioms.h" |
| #include "HTMLSelectElement.h" |
| #include "HTMLSlotElement.h" |
| #include "HTMLSummaryElement.h" |
| #include "HTMLTextAreaElement.h" |
| #include "HTMLTextFormControlElement.h" |
| #include "HTMLVideoElement.h" |
| #include "HitTestSource.h" |
| #include "KeyboardEvent.h" |
| #include "LocalFrame.h" |
| #include "LocalFrameView.h" |
| #include "LocalizedStrings.h" |
| #include "MathMLElement.h" |
| #include "MathMLNames.h" |
| #include "NodeList.h" |
| #include "NodeTraversal.h" |
| #include "ProgressTracker.h" |
| #include "RenderImage.h" |
| #include "RenderTableCell.h" |
| #include "RenderView.h" |
| #include "SVGElement.h" |
| #include "ShadowRoot.h" |
| #include "Text.h" |
| #include "TextControlInnerElements.h" |
| #include "TextIterator.h" |
| #include "TypedElementDescendantIteratorInlines.h" |
| #include "UserGestureIndicator.h" |
| #include "VisibleUnits.h" |
| #include <numeric> |
| #include <wtf/Scope.h> |
| #include <wtf/SetForScope.h> |
| #include <wtf/StdLibExtras.h> |
| #include <wtf/text/StringBuilder.h> |
| #include <wtf/unicode/CharacterNames.h> |
| |
| namespace WebCore { |
| |
| using namespace HTMLNames; |
| |
| static String accessibleNameForNode(Node&, Node* labelledbyNode = nullptr); |
| static void appendNameToStringBuilder(StringBuilder&, String&&, bool prependSpace = true); |
| |
| AccessibilityNodeObject::AccessibilityNodeObject(AXID axID, Node* node, AXObjectCache& cache) |
| : AccessibilityObject(axID, cache) |
| , m_node(node) |
| { |
| } |
| |
| AccessibilityNodeObject::~AccessibilityNodeObject() |
| { |
| ASSERT(isDetached()); |
| } |
| |
| void AccessibilityNodeObject::init() |
| { |
| #ifndef NDEBUG |
| ASSERT(!m_initialized); |
| m_initialized = true; |
| #endif |
| AccessibilityObject::init(); |
| } |
| |
| Ref<AccessibilityNodeObject> AccessibilityNodeObject::create(AXID axID, Node& node, AXObjectCache& cache) |
| { |
| return adoptRef(*new AccessibilityNodeObject(axID, &node, cache)); |
| } |
| |
| void AccessibilityNodeObject::detachRemoteParts(AccessibilityDetachmentType detachmentType) |
| { |
| // AccessibilityObject calls clearChildren. |
| AccessibilityObject::detachRemoteParts(detachmentType); |
| m_node = nullptr; |
| } |
| |
| AccessibilityObject* AccessibilityNodeObject::firstChild() const |
| { |
| RefPtr currentChild = node() ? node()->firstChild() : nullptr; |
| if (!currentChild) |
| return nullptr; |
| |
| auto* cache = axObjectCache(); |
| if (!cache) |
| return nullptr; |
| |
| RefPtr axCurrentChild = cache->getOrCreate(*currentChild); |
| while (!axCurrentChild && currentChild) { |
| currentChild = currentChild->nextSibling(); |
| axCurrentChild = cache->getOrCreate(currentChild.get()); |
| } |
| return axCurrentChild.get(); |
| } |
| |
| AccessibilityObject* AccessibilityNodeObject::lastChild() const |
| { |
| if (!node()) |
| return nullptr; |
| |
| RefPtr lastChild = node()->lastChild(); |
| if (!lastChild) |
| return nullptr; |
| |
| auto objectCache = axObjectCache(); |
| return objectCache ? objectCache->getOrCreate(*lastChild) : nullptr; |
| } |
| |
| AccessibilityObject* AccessibilityNodeObject::previousSibling() const |
| { |
| if (!node()) |
| return nullptr; |
| |
| RefPtr previousSibling = node()->previousSibling(); |
| if (!previousSibling) |
| return nullptr; |
| |
| auto objectCache = axObjectCache(); |
| return objectCache ? objectCache->getOrCreate(*previousSibling) : nullptr; |
| } |
| |
| AccessibilityObject* AccessibilityNodeObject::nextSibling() const |
| { |
| if (!node()) |
| return nullptr; |
| |
| RefPtr nextSibling = node()->nextSibling(); |
| if (!nextSibling) |
| return nullptr; |
| |
| auto objectCache = axObjectCache(); |
| return objectCache ? objectCache->getOrCreate(*nextSibling) : nullptr; |
| } |
| |
| AccessibilityObject* AccessibilityNodeObject::ownerParentObject() const |
| { |
| auto owners = this->owners(); |
| ASSERT(owners.size() <= 1); |
| return owners.size() ? dynamicDowncast<AccessibilityObject>(owners.first().get()) : nullptr; |
| } |
| |
| AccessibilityObject* AccessibilityNodeObject::parentObject() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return nullptr; |
| |
| if (RefPtr ownerParent = ownerParentObject()) [[unlikely]] |
| return ownerParent.get(); |
| |
| CheckedPtr cache = axObjectCache(); |
| #if USE(ATSPI) |
| // FIXME: Consider removing this ATSPI-only branch with https://bugs.webkit.org/show_bug.cgi?id=282117. |
| return cache ? cache->getOrCreate(node->parentNode()) : nullptr; |
| #else |
| return cache ? cache->getOrCreate(composedParentIgnoringDocumentFragments(*node)) : nullptr; |
| #endif // USE(ATSPI) |
| } |
| |
| LayoutRect AccessibilityNodeObject::checkboxOrRadioRect() const |
| { |
| auto labels = Accessibility::labelsForElement(element()); |
| if (labels.isEmpty()) |
| return boundingBoxRect(); |
| |
| auto* cache = axObjectCache(); |
| if (!cache) |
| return boundingBoxRect(); |
| |
| // A checkbox or radio button should encompass its label. |
| auto selfRect = boundingBoxRect(); |
| for (auto& label : labels) { |
| if (label->renderer()) { |
| if (RefPtr axLabel = cache->getOrCreate(label.get())) |
| selfRect.unite(axLabel->elementRect()); |
| } |
| } |
| return selfRect; |
| } |
| |
| LayoutRect AccessibilityNodeObject::elementRect() const |
| { |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(node()); input && (input->isCheckbox() || input->isRadioButton())) |
| return checkboxOrRadioRect(); |
| |
| return boundingBoxRect(); |
| } |
| |
| LayoutRect AccessibilityNodeObject::boundingBoxRect() const |
| { |
| if (hasDisplayContents()) { |
| LayoutRect contentsRect; |
| for (const auto& child : const_cast<AccessibilityNodeObject*>(this)->unignoredChildren()) |
| contentsRect.unite(child->elementRect()); |
| |
| if (!contentsRect.isEmpty()) |
| return contentsRect; |
| } |
| |
| // Non-display:contents AccessibilityNodeObjects have no mechanism to return a size or position. |
| // Instead, let's return a box at the position of an ancestor that does have a position, make it |
| // the width of that ancestor, and about the height of a line of text, so it's clear this object is |
| // a descendant of that ancestor. |
| return nonEmptyAncestorBoundingBox(); |
| } |
| |
| LayoutRect AccessibilityNodeObject::nonEmptyAncestorBoundingBox() const |
| { |
| for (RefPtr<AccessibilityObject> ancestor = parentObject(); ancestor; ancestor = ancestor->parentObject()) { |
| if (!ancestor->renderer()) |
| continue; |
| auto ancestorRect = ancestor->elementRect(); |
| if (ancestorRect.isEmpty()) |
| continue; |
| |
| return { |
| ancestorRect.location(), |
| LayoutSize(ancestorRect.width(), LayoutUnit(std::min(10.0f, ancestorRect.height().toFloat()))) |
| }; |
| } |
| // Fallback to returning a default, non-empty rect at 0, 0. |
| return { 0, 0, 1, 1 }; |
| } |
| |
| Document* AccessibilityNodeObject::document() const |
| { |
| if (!node()) |
| return nullptr; |
| return &node()->document(); |
| } |
| |
| LocalFrameView* AccessibilityNodeObject::documentFrameView() const |
| { |
| if (auto* node = this->node()) |
| return node->document().view(); |
| return AccessibilityObject::documentFrameView(); |
| } |
| |
| AccessibilityRole AccessibilityNodeObject::determineAccessibilityRole() |
| { |
| AXTRACE("AccessibilityNodeObject::determineAccessibilityRole"_s); |
| if ((m_ariaRole = determineAriaRoleAttribute()) != AccessibilityRole::Unknown) |
| return m_ariaRole; |
| |
| return determineAccessibilityRoleFromNode(); |
| } |
| |
| bool AccessibilityNodeObject::matchesTextAreaRole() const |
| { |
| return is<HTMLTextAreaElement>(node()) || hasContentEditableAttributeSet(); |
| } |
| |
| AccessibilityRole AccessibilityNodeObject::determineAccessibilityRoleFromNode(TreatStyleFormatGroupAsInline treatStyleFormatGroupAsInline) const |
| { |
| AXTRACE("AccessibilityNodeObject::determineAccessibilityRoleFromNode"_s); |
| |
| RefPtr node = this->node(); |
| if (!node) |
| return AccessibilityRole::Unknown; |
| |
| if (node->isTextNode()) |
| return AccessibilityRole::StaticText; |
| |
| RefPtr element = dynamicDowncast<HTMLElement>(*node); |
| if (!element) |
| return AccessibilityRole::Unknown; |
| |
| if (element->isLink()) |
| return AccessibilityRole::Link; |
| if (RefPtr selectElement = dynamicDowncast<HTMLSelectElement>(*element)) |
| return selectElement->multiple() ? AccessibilityRole::ListBox : AccessibilityRole::PopUpButton; |
| if (is<HTMLImageElement>(*element) && element->hasAttributeWithoutSynchronization(usemapAttr)) |
| return AccessibilityRole::ImageMap; |
| |
| auto elementName = element->elementName(); |
| if (elementName == ElementName::HTML_li) |
| return AccessibilityRole::ListItem; |
| if (elementName == ElementName::HTML_button) |
| return buttonRoleType(); |
| if (elementName == ElementName::HTML_legend) |
| return AccessibilityRole::Legend; |
| if (elementName == ElementName::HTML_canvas) |
| return AccessibilityRole::Canvas; |
| |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(*element)) |
| return roleFromInputElement(*input); |
| |
| if (matchesTextAreaRole()) |
| return AccessibilityRole::TextArea; |
| |
| if (headingLevel()) |
| return AccessibilityRole::Heading; |
| |
| if (elementName == ElementName::HTML_code) |
| return AccessibilityRole::Code; |
| if (elementName == ElementName::HTML_del || elementName == ElementName::HTML_s) |
| return AccessibilityRole::Deletion; |
| if (elementName == ElementName::HTML_ins) |
| return AccessibilityRole::Insertion; |
| if (elementName == ElementName::HTML_sub) |
| return AccessibilityRole::Subscript; |
| if (elementName == ElementName::HTML_sup) |
| return AccessibilityRole::Superscript; |
| if (elementName == ElementName::HTML_strong) |
| return AccessibilityRole::Strong; |
| |
| if (elementName == ElementName::HTML_kbd |
| || elementName == ElementName::HTML_pre |
| || elementName == ElementName::HTML_samp |
| || elementName == ElementName::HTML_var |
| || elementName == ElementName::HTML_cite) |
| return treatStyleFormatGroupAsInline == TreatStyleFormatGroupAsInline::Yes ? AccessibilityRole::Inline : AccessibilityRole::TextGroup; |
| |
| if (elementName == ElementName::HTML_dd) |
| return AccessibilityRole::DescriptionListDetail; |
| if (elementName == ElementName::HTML_dt) |
| return AccessibilityRole::DescriptionListTerm; |
| if (elementName == ElementName::HTML_dl) |
| return AccessibilityRole::DescriptionList; |
| |
| if (elementName == ElementName::HTML_menu |
| || elementName == ElementName::HTML_ol |
| || elementName == ElementName::HTML_ul) |
| return AccessibilityRole::List; |
| |
| if (elementName == ElementName::HTML_fieldset) |
| return AccessibilityRole::Group; |
| if (elementName == ElementName::HTML_figure) |
| return AccessibilityRole::Figure; |
| if (elementName == ElementName::HTML_p) |
| return AccessibilityRole::Paragraph; |
| |
| if (is<HTMLLabelElement>(*element)) |
| return AccessibilityRole::Label; |
| if (elementName == ElementName::HTML_dfn) { |
| // Confusingly, the `dfn` element represents a term being defined, making it equivalent to the "term" ARIA |
| // role rather than the "definition" ARIA role. The "definition" ARIA role has no HTML equivalent. |
| // https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-dfn-element |
| // https://w3c.github.io/aria/#term and https://w3c.github.io/aria/#definition |
| return AccessibilityRole::Term; |
| } |
| if (elementName == ElementName::HTML_div && !isNonNativeTextControl()) |
| return AccessibilityRole::Generic; |
| if (is<HTMLFormElement>(*element)) |
| return AccessibilityRole::Form; |
| if (elementName == ElementName::HTML_article) |
| return AccessibilityRole::DocumentArticle; |
| if (elementName == ElementName::HTML_main) |
| return AccessibilityRole::LandmarkMain; |
| if (elementName == ElementName::HTML_nav) |
| return AccessibilityRole::LandmarkNavigation; |
| |
| if (elementName == ElementName::HTML_aside) { |
| if (ariaRoleAttribute() == AccessibilityRole::LandmarkComplementary || !isDescendantOfElementType({ asideTag, articleTag, sectionTag, navTag })) |
| return AccessibilityRole::LandmarkComplementary; |
| |
| // https://w3c.github.io/html-aam/#el-aside |
| // When within a sectioning content elements, complementary landmarks must have accnames to acquire the role. |
| return WebCore::hasAccNameAttribute(*element) ? AccessibilityRole::LandmarkComplementary : AccessibilityRole::Generic; |
| } |
| |
| if (elementName == ElementName::HTML_search) |
| return AccessibilityRole::LandmarkSearch; |
| |
| if (elementName == ElementName::HTML_section) { |
| // https://w3c.github.io/html-aam/#el-section |
| // The default role attribute value for the section element, region, became a landmark in ARIA 1.1. |
| // The HTML AAM spec says it is "strongly recommended" that ATs only convey and provide navigation |
| // for section elements which have names. |
| return WebCore::hasAccNameAttribute(*element) ? AccessibilityRole::LandmarkRegion : AccessibilityRole::TextGroup; |
| } |
| if (elementName == ElementName::HTML_address) |
| return AccessibilityRole::Group; |
| if (elementName == ElementName::HTML_blockquote) |
| return AccessibilityRole::Blockquote; |
| if (elementName == ElementName::HTML_caption || elementName == ElementName::HTML_figcaption) |
| return AccessibilityRole::Caption; |
| if (elementName == ElementName::HTML_dialog) |
| return AccessibilityRole::ApplicationDialog; |
| if (elementName == ElementName::HTML_mark || equalLettersIgnoringASCIICase(getAttribute(roleAttr), "mark"_s)) |
| return AccessibilityRole::Mark; |
| if (is<HTMLDetailsElement>(*element)) |
| return AccessibilityRole::Details; |
| if (RefPtr summaryElement = dynamicDowncast<HTMLSummaryElement>(*element); summaryElement && summaryElement->isActiveSummary()) |
| return AccessibilityRole::Summary; |
| |
| // http://rawgit.com/w3c/aria/master/html-aam/html-aam.html |
| // Output elements should be mapped to status role. |
| if (isOutput()) |
| return AccessibilityRole::ApplicationStatus; |
| |
| #if ENABLE(VIDEO) |
| if (is<HTMLVideoElement>(*element)) |
| return AccessibilityRole::Video; |
| if (is<HTMLAudioElement>(*element)) |
| return AccessibilityRole::Audio; |
| #endif |
| |
| #if ENABLE(MODEL_ELEMENT) |
| if (elementName == ElementName::HTML_model) |
| return AccessibilityRole::Model; |
| #endif |
| |
| // The HTML element should not be exposed as an element. That's what the RenderView element does. |
| if (elementName == ElementName::HTML_html) |
| return AccessibilityRole::Ignored; |
| |
| // There should only be one role="banner" per page. |
| // https://w3c.github.io/html-aam/#el-header-ancestorbody |
| // Footer elements should be role="banner" if scoped to body, and consequently become a landmark. |
| if (elementName == ElementName::HTML_header) { |
| if (!isDescendantOfElementType({ articleTag, asideTag, mainTag, navTag, sectionTag })) |
| return AccessibilityRole::LandmarkBanner; |
| |
| // https://github.com/w3c/aria/pull/1931 |
| // A <header> that is a descendant of <main> or a sectioning element should be role="sectionheader". |
| return AccessibilityRole::SectionHeader; |
| } |
| |
| // There should only be one role="contentinfo" per page. |
| // https://w3c.github.io/html-aam/#el-footer-ancestorbody |
| // Footer elements should be role="contentinfo" if scoped to body, and consequently become a landmark. |
| if (elementName == ElementName::HTML_footer) { |
| if (!isDescendantOfElementType({ articleTag, asideTag, mainTag, navTag, sectionTag })) |
| return AccessibilityRole::LandmarkContentInfo; |
| |
| // https://github.com/w3c/aria/pull/1931 |
| // A <footer> that is a descendant of <main> or a sectioning element should be role="sectionfooter". |
| return AccessibilityRole::SectionFooter; |
| } |
| |
| if (elementName == ElementName::HTML_time) |
| return AccessibilityRole::Time; |
| if (elementName == ElementName::HTML_hr) |
| return AccessibilityRole::HorizontalRule; |
| if (elementName == ElementName::HTML_em) |
| return AccessibilityRole::Emphasis; |
| if (elementName == ElementName::HTML_hgroup) |
| return AccessibilityRole::Group; |
| |
| // If the element does not have role, but it has ARIA attributes, or accepts tab focus, accessibility should fallback to exposing it as a group. |
| if (supportsARIAAttributes() || canSetFocusAttribute() || element->isFocusable()) |
| return AccessibilityRole::Group; |
| |
| return AccessibilityRole::Unknown; |
| } |
| |
| AccessibilityRole AccessibilityNodeObject::roleFromInputElement(const HTMLInputElement& input) const |
| { |
| AXTRACE("AccessibilityNodeObject::roleFromInputElement"_s); |
| ASSERT(dynamicDowncast<HTMLInputElement>(node()) == &input); |
| |
| if (input.isTextButton()) |
| return buttonRoleType(); |
| if (input.isSwitch()) |
| return AccessibilityRole::Switch; |
| if (input.isCheckbox()) |
| return AccessibilityRole::Checkbox; |
| if (input.isRadioButton()) |
| return AccessibilityRole::RadioButton; |
| |
| if (input.isTextField()) { |
| // Text fields may have a combobox ancestor, in which case we want to return role combobox. |
| // This was ARIA 1.1 practice, but it has been recommended against since. Keeping this heuristics here in order to support those sites that are still using this structure. |
| bool foundCombobox = false; |
| for (RefPtr ancestor = parentObject(); ancestor; ancestor = ancestor->parentObject()) { |
| if (ancestor->isComboBox()) { |
| foundCombobox = true; |
| break; |
| } |
| if (!ancestor->isGroup() && ancestor->role() != AccessibilityRole::Generic) |
| break; |
| } |
| if (foundCombobox) |
| return AccessibilityRole::ComboBox; |
| |
| return input.isSearchField() ? AccessibilityRole::SearchField : AccessibilityRole::TextField; |
| } |
| |
| if (input.isDateField() || input.isDateTimeLocalField() || input.isMonthField() || input.isTimeField() || input.isWeekField()) |
| return AccessibilityRole::DateTime; |
| if (input.isFileUpload()) |
| return AccessibilityRole::Button; |
| if (input.isColorControl()) |
| return AccessibilityRole::ColorWell; |
| if (input.isInputTypeHidden()) |
| return AccessibilityRole::Ignored; |
| if (input.isRangeControl()) |
| return AccessibilityRole::Slider; |
| |
| // All other input type is treated as a text field. |
| return AccessibilityRole::TextField; |
| } |
| |
| bool AccessibilityNodeObject::isDescendantOfElementType(const HashSet<QualifiedName>& tagNames) const |
| { |
| if (!m_node) |
| return false; |
| |
| for (Ref ancestorElement : ancestorsOfType<Element>(*m_node)) { |
| if (tagNames.contains(ancestorElement->tagQName())) |
| return true; |
| } |
| return false; |
| } |
| |
| void AccessibilityNodeObject::updateChildrenIfNecessary() |
| { |
| if (needsToUpdateChildren()) |
| clearChildren(); |
| |
| AccessibilityObject::updateChildrenIfNecessary(); |
| } |
| |
| void AccessibilityNodeObject::clearChildren() |
| { |
| AccessibilityObject::clearChildren(); |
| m_childrenDirty = false; |
| } |
| |
| void AccessibilityNodeObject::updateOwnedChildren() |
| { |
| bool didRemoveChild = false; |
| auto ownedObjects = this->ownedObjects(); |
| for (const auto& child : ownedObjects) { |
| if (m_children.removeFirst(child)) { |
| // If the child already exists as a DOM child, but is also in the owned objects, then |
| // we need to re-order this child in the aria-owns order. |
| didRemoveChild = true; |
| } |
| addChild(downcast<AccessibilityObject>(child.get())); |
| } |
| |
| if (didRemoveChild) { |
| // Fix-up the children index-in-parent fields after removing a child in the middle of m_children, |
| // as any index after the removed child will now be wrong. |
| resetChildrenIndexInParent(); |
| } |
| } |
| |
| void AccessibilityNodeObject::addChildren() |
| { |
| // If the need to add more children in addition to existing children arises, |
| // childrenChanged should have been called, leaving the object with no children. |
| ASSERT(!m_childrenInitialized); |
| m_childrenInitialized = true; |
| |
| auto clearDirtySubtree = makeScopeExit([&] { |
| m_subtreeDirty = false; |
| }); |
| |
| RefPtr node = this->node(); |
| if (!node) |
| return; |
| |
| // The only time we add children from the DOM tree to a node with a renderer is when it's a canvas. |
| if (renderer() && WebCore::elementName(*node) != ElementName::HTML_canvas) |
| return; |
| |
| CheckedPtr cache = axObjectCache(); |
| if (!cache) |
| return; |
| |
| #if USE(ATSPI) |
| // FIXME: Consider removing this ATSPI-only branch with https://bugs.webkit.org/show_bug.cgi?id=282117. |
| for (auto* child = node->firstChild(); child; child = child->nextSibling()) |
| addChild(cache->getOrCreate(*child)); |
| #else |
| if (RefPtr containerNode = dynamicDowncast<ContainerNode>(*node)) { |
| // Specify an InlineContextCapacity template parameter of 0 to avoid allocating ComposedTreeIterator's |
| // internal vector on the stack. See comment in AccessibilityRenderObject::addChildren() for a full |
| // explanation of this behavior. |
| for (Ref child : composedTreeChildren</* InlineContextCapacity */ 0>(*containerNode)) |
| addChild(cache->getOrCreate(child.get())); |
| } |
| #endif // USE(ATSPI) |
| |
| updateOwnedChildren(); |
| |
| #ifndef NDEBUG |
| verifyChildrenIndexInParent(); |
| #endif |
| } |
| |
| bool AccessibilityNodeObject::canHaveChildren() const |
| { |
| // When <noscript> is not being used (its renderer() == 0), ignore its children |
| if (node() && !renderer() && WebCore::elementName(node()) == ElementName::HTML_noscript) |
| return false; |
| // If this is an AccessibilityRenderObject, then it's okay if this object |
| // doesn't have a node - there are some renderers that don't have associated |
| // nodes, like scroll areas and css-generated text. |
| |
| // Elements that should not have children. |
| switch (role()) { |
| case AccessibilityRole::Button: |
| #if !USE(ATSPI) |
| // GTK/ATSPI layout tests expect popup buttons to have children. |
| case AccessibilityRole::PopUpButton: |
| #endif |
| case AccessibilityRole::Checkbox: |
| case AccessibilityRole::RadioButton: |
| case AccessibilityRole::Tab: |
| case AccessibilityRole::ToggleButton: |
| case AccessibilityRole::StaticText: |
| case AccessibilityRole::ListBoxOption: |
| case AccessibilityRole::ScrollBar: |
| case AccessibilityRole::ProgressIndicator: |
| case AccessibilityRole::Switch: |
| case AccessibilityRole::MenuItemCheckbox: |
| case AccessibilityRole::MenuItemRadio: |
| case AccessibilityRole::Splitter: |
| case AccessibilityRole::Meter: |
| return false; |
| default: |
| return true; |
| } |
| } |
| |
| AXCoreObject::AccessibilityChildrenVector AccessibilityNodeObject::visibleChildren() |
| { |
| // Only listboxes are asked for their visible children. |
| // Native list boxes would be AccessibilityListBoxes, so only check for aria list boxes. |
| if (ariaRoleAttribute() != AccessibilityRole::ListBox) |
| return { }; |
| |
| if (!childrenInitialized()) |
| addChildren(); |
| |
| AccessibilityChildrenVector result; |
| for (const auto& child : unignoredChildren()) { |
| if (!child->isOffScreen()) |
| result.append(child); |
| } |
| return result; |
| } |
| |
| bool AccessibilityNodeObject::computeIsIgnored() const |
| { |
| #ifndef NDEBUG |
| // Double-check that an AccessibilityObject is never accessed before |
| // it's been initialized. |
| ASSERT(m_initialized); |
| #endif |
| RefPtr node = this->node(); |
| if (!node) |
| return true; |
| |
| // Handle non-rendered text that is exposed through aria-hidden=false. |
| if (node->isTextNode() && !renderer()) { |
| RefPtr parent = node->parentNode(); |
| // Fallback content in iframe nodes should be ignored. |
| if (WebCore::elementName(parent.get()) == ElementName::HTML_iframe && parent->renderer()) |
| return true; |
| |
| // Whitespace only text elements should be ignored when they have no renderer. |
| if (stringValue().containsOnly<isASCIIWhitespace>()) |
| return true; |
| } |
| |
| AccessibilityObjectInclusion decision = defaultObjectInclusion(); |
| if (decision == AccessibilityObjectInclusion::IncludeObject) |
| return false; |
| if (decision == AccessibilityObjectInclusion::IgnoreObject) |
| return true; |
| |
| auto role = this->role(); |
| if (role == AccessibilityRole::Ignored || role == AccessibilityRole::Unknown) |
| return true; |
| |
| if (isRenderHidden() && !ancestorsOfType<HTMLCanvasElement>(*node).first()) { |
| // Only allow display:none / hidden-visibility node-only objects for canvas subtrees. |
| return true; |
| } |
| return false; |
| } |
| |
| bool AccessibilityNodeObject::hasElementDescendant() const |
| { |
| RefPtr element = dynamicDowncast<Element>(node()); |
| return element && childrenOfType<Element>(*element).first(); |
| } |
| |
| static bool isFlowContent(Node& node) |
| { |
| if (auto* element = dynamicDowncast<HTMLElement>(node)) { |
| // https://html.spec.whatwg.org/#flow-content |
| // Below represents a non-comprehensive list of common flow content elements. |
| const AtomString& tag = element->localName(); |
| if (tag == blockquoteTag |
| || tag == canvasTag |
| || tag == codeTag |
| || tag == divTag |
| || tag == olTag |
| || tag == pictureTag |
| || tag == preTag |
| || tag == pTag |
| || tag == spanTag |
| || tag == ulTag) |
| return true; |
| } |
| |
| auto* text = dynamicDowncast<Text>(node); |
| return text && !text->data().containsOnly<isASCIIWhitespace>(); |
| } |
| |
| bool AccessibilityNodeObject::isNativeTextControl() const |
| { |
| if (is<HTMLTextAreaElement>(node())) |
| return true; |
| |
| RefPtr input = dynamicDowncast<HTMLInputElement>(node()); |
| return input && (input->isText() || input->isNumberField()); |
| } |
| |
| bool AccessibilityNodeObject::isSearchField() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return false; |
| |
| if (role() == AccessibilityRole::SearchField) |
| return true; |
| |
| RefPtr inputElement = dynamicDowncast<HTMLInputElement>(*node); |
| if (!inputElement) |
| return false; |
| |
| // Some websites don't label their search fields as such. However, they will |
| // use the word "search" in either the form or input type. This won't catch every case, |
| // but it will catch google.com for example. |
| |
| // Check the node name of the input type, sometimes it's "search". |
| const AtomString& nameAttribute = getAttribute(nameAttr); |
| if (nameAttribute.containsIgnoringASCIICase("search"_s)) |
| return true; |
| |
| // Check the form action and the name, which will sometimes be "search". |
| RefPtr form = inputElement->form(); |
| if (form && (form->name().containsIgnoringASCIICase("search"_s) || form->action().containsIgnoringASCIICase("search"_s))) |
| return true; |
| |
| return false; |
| } |
| |
| bool AccessibilityNodeObject::isNativeImage() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return false; |
| |
| if (is<HTMLImageElement>(*node)) |
| return true; |
| |
| auto elementName = WebCore::elementName(*node); |
| if (elementName == ElementName::HTML_applet || elementName == ElementName::HTML_embed || elementName == ElementName::HTML_object) |
| return true; |
| |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(*node)) |
| return input->isImageButton(); |
| |
| return false; |
| } |
| |
| bool AccessibilityNodeObject::isSecureField() const |
| { |
| RefPtr input = dynamicDowncast<HTMLInputElement>(node()); |
| if (!input || ariaRoleAttribute() != AccessibilityRole::Unknown) |
| return false; |
| return input->isSecureField(); |
| } |
| |
| bool AccessibilityNodeObject::isEnabled() const |
| { |
| // ARIA says that the disabled status applies to the current element and all descendant elements. |
| for (AccessibilityObject* object = const_cast<AccessibilityNodeObject*>(this); object; object = object->parentObject()) { |
| const AtomString& disabledStatus = object->getAttribute(aria_disabledAttr); |
| if (equalLettersIgnoringASCIICase(disabledStatus, "true"_s)) |
| return false; |
| if (equalLettersIgnoringASCIICase(disabledStatus, "false"_s)) |
| break; |
| } |
| |
| if (role() == AccessibilityRole::HorizontalRule) |
| return false; |
| |
| RefPtr element = dynamicDowncast<Element>(node()); |
| return !element || !element->isDisabledFormControl(); |
| } |
| |
| bool AccessibilityNodeObject::isIndeterminate() const |
| { |
| if (supportsCheckedState()) |
| return checkboxOrRadioValue() == AccessibilityButtonState::Mixed; |
| |
| // We handle this for native <progress> elements in AccessibilityProgressIndicator::isIndeterminate. |
| if (ariaRoleAttribute() == AccessibilityRole::ProgressIndicator) |
| return !hasARIAValueNow(); |
| |
| return false; |
| } |
| |
| bool AccessibilityNodeObject::isPressed() const |
| { |
| if (!isButton()) |
| return false; |
| |
| RefPtr node = this->node(); |
| if (!node) |
| return false; |
| |
| // If this is an toggle button, check the aria-pressed attribute rather than node()->active() |
| if (isToggleButton()) |
| return equalLettersIgnoringASCIICase(getAttribute(aria_pressedAttr), "true"_s); |
| |
| RefPtr element = dynamicDowncast<Element>(*node); |
| return element && element->active(); |
| } |
| |
| bool AccessibilityNodeObject::isChecked() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return false; |
| |
| // First test for native checkedness semantics |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(*node)) |
| return input->matchesCheckedPseudoClass(); |
| |
| // Else, if this is an ARIA checkbox or radio, respect the aria-checked attribute |
| bool validRole = false; |
| switch (ariaRoleAttribute()) { |
| case AccessibilityRole::RadioButton: |
| case AccessibilityRole::Checkbox: |
| case AccessibilityRole::MenuItem: |
| case AccessibilityRole::MenuItemCheckbox: |
| case AccessibilityRole::MenuItemRadio: |
| case AccessibilityRole::Switch: |
| case AccessibilityRole::TreeItem: |
| validRole = true; |
| break; |
| default: |
| break; |
| } |
| |
| if (validRole && equalLettersIgnoringASCIICase(getAttribute(aria_checkedAttr), "true"_s)) |
| return true; |
| |
| return false; |
| } |
| |
| bool AccessibilityNodeObject::isMultiSelectable() const |
| { |
| const AtomString& ariaMultiSelectable = getAttribute(aria_multiselectableAttr); |
| if (equalLettersIgnoringASCIICase(ariaMultiSelectable, "true"_s)) |
| return true; |
| if (equalLettersIgnoringASCIICase(ariaMultiSelectable, "false"_s)) |
| return false; |
| |
| RefPtr select = dynamicDowncast<HTMLSelectElement>(node()); |
| return select && select->multiple(); |
| } |
| |
| bool AccessibilityNodeObject::isRequired() const |
| { |
| RefPtr formControlElement = dynamicDowncast<HTMLFormControlElement>(node()); |
| if (formControlElement && formControlElement->isRequired()) |
| return true; |
| |
| const AtomString& requiredValue = getAttribute(aria_requiredAttr); |
| if (equalLettersIgnoringASCIICase(requiredValue, "true"_s)) |
| return true; |
| if (equalLettersIgnoringASCIICase(requiredValue, "false"_s)) |
| return false; |
| |
| return false; |
| } |
| |
| String AccessibilityNodeObject::accessKey() const |
| { |
| RefPtr element = this->element(); |
| return element ? element->attributeWithoutSynchronization(accesskeyAttr) : String(); |
| } |
| |
| bool AccessibilityNodeObject::supportsDropping() const |
| { |
| return determineDropEffects().size(); |
| } |
| |
| bool AccessibilityNodeObject::supportsDragging() const |
| { |
| const AtomString& grabbed = getAttribute(aria_grabbedAttr); |
| return equalLettersIgnoringASCIICase(grabbed, "true"_s) || equalLettersIgnoringASCIICase(grabbed, "false"_s) || hasAttribute(draggableAttr); |
| } |
| |
| bool AccessibilityNodeObject::isGrabbed() |
| { |
| #if ENABLE(DRAG_SUPPORT) |
| if (RefPtr localMainFrame = this->localMainFrame()) { |
| if (localMainFrame->eventHandler().draggingElement() == element()) |
| return true; |
| } |
| #endif |
| |
| return elementAttributeValue(aria_grabbedAttr); |
| } |
| |
| Vector<String> AccessibilityNodeObject::determineDropEffects() const |
| { |
| // Order is aria-dropeffect, dropzone, webkitdropzone |
| const AtomString& dropEffects = getAttribute(aria_dropeffectAttr); |
| if (!dropEffects.isEmpty()) |
| return makeStringByReplacingAll(dropEffects.string(), '\n', ' ').split(' '); |
| |
| auto dropzone = getAttribute(dropzoneAttr); |
| if (!dropzone.isEmpty()) |
| return Vector<String> { dropzone }; |
| |
| auto webkitdropzone = getAttribute(webkitdropzoneAttr); |
| if (!webkitdropzone.isEmpty()) |
| return Vector<String> { webkitdropzone }; |
| |
| // FIXME: We should return drop effects for elements with `dragenter` and `dragover` event handlers. |
| // dropzone and webkitdropzone used to serve this purpose, but are deprecated in favor of the |
| // aforementioned event handlers. |
| // |
| // https://html.spec.whatwg.org/dev/obsolete.html: |
| // "dropzone on all elements: Use script to handle the dragenter and dragover events instead." |
| return { }; |
| } |
| |
| bool AccessibilityNodeObject::supportsARIAOwns() const |
| { |
| return !getAttribute(aria_ownsAttr).isEmpty(); |
| } |
| |
| AXCoreObject::AccessibilityChildrenVector AccessibilityNodeObject::radioButtonGroup() const |
| { |
| AccessibilityChildrenVector result; |
| |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(node())) { |
| auto radioButtonGroup = input->radioButtonGroup(); |
| result.reserveInitialCapacity(radioButtonGroup.size()); |
| |
| WeakPtr cache = axObjectCache(); |
| for (auto& radioSibling : radioButtonGroup) { |
| if (!cache) |
| break; |
| if (RefPtr object = cache->getOrCreate(radioSibling.ptr())) |
| result.append(object.releaseNonNull()); |
| } |
| } |
| |
| return result; |
| } |
| |
| String AccessibilityNodeObject::valueDescription() const |
| { |
| if (!isRangeControl()) |
| return String(); |
| |
| return getAttribute(aria_valuetextAttr).string(); |
| } |
| |
| float AccessibilityNodeObject::valueForRange() const |
| { |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(node()); input && input->isRangeControl()) |
| return input->valueAsNumber(); |
| |
| if (!isRangeControl()) |
| return 0.0f; |
| |
| // In ARIA 1.1, the implicit value for aria-valuenow on a spin button is 0. |
| // For other roles, it is half way between aria-valuemin and aria-valuemax. |
| auto& value = getAttribute(aria_valuenowAttr); |
| if (!value.isEmpty()) |
| return value.toFloat(); |
| |
| return isSpinButton() ? 0 : std::midpoint(minValueForRange(), maxValueForRange()); |
| } |
| |
| float AccessibilityNodeObject::maxValueForRange() const |
| { |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(node()); input && input->isRangeControl()) |
| return input->maximum(); |
| |
| if (!isRangeControl()) |
| return 0.0f; |
| |
| auto& value = getAttribute(aria_valuemaxAttr); |
| if (!value.isEmpty()) |
| return value.toFloat(); |
| |
| // In ARIA 1.1, the implicit value for aria-valuemax on a spin button |
| // is that there is no maximum value. For other roles, it is 100. |
| return isSpinButton() ? std::numeric_limits<float>::max() : 100.0f; |
| } |
| |
| float AccessibilityNodeObject::minValueForRange() const |
| { |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(node()); input && input->isRangeControl()) |
| return input->minimum(); |
| |
| if (!isRangeControl()) |
| return 0.0f; |
| |
| auto& value = getAttribute(aria_valueminAttr); |
| if (!value.isEmpty()) |
| return value.toFloat(); |
| |
| // In ARIA 1.1, the implicit value for aria-valuemin on a spin button |
| // is that there is no minimum value. For other roles, it is 0. |
| return isSpinButton() ? -std::numeric_limits<float>::max() : 0.0f; |
| } |
| |
| float AccessibilityNodeObject::stepValueForRange() const |
| { |
| return getAttribute(stepAttr).toFloat(); |
| } |
| |
| std::optional<AccessibilityOrientation> AccessibilityNodeObject::orientationFromARIA() const |
| { |
| const AtomString& ariaOrientation = getAttribute(aria_orientationAttr); |
| if (equalLettersIgnoringASCIICase(ariaOrientation, "horizontal"_s)) |
| return AccessibilityOrientation::Horizontal; |
| if (equalLettersIgnoringASCIICase(ariaOrientation, "vertical"_s)) |
| return AccessibilityOrientation::Vertical; |
| if (equalLettersIgnoringASCIICase(ariaOrientation, "undefined"_s)) |
| return AccessibilityOrientation::Undefined; |
| |
| return std::nullopt; |
| } |
| |
| bool AccessibilityNodeObject::isBusy() const |
| { |
| return elementAttributeValue(aria_busyAttr); |
| } |
| |
| bool AccessibilityNodeObject::isFieldset() const |
| { |
| return elementName() == ElementName::HTML_fieldset; |
| } |
| |
| AccessibilityButtonState AccessibilityNodeObject::checkboxOrRadioValue() const |
| { |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(node()); input && (input->isCheckbox() || input->isRadioButton())) |
| return input->indeterminate() && !input->isSwitch() ? AccessibilityButtonState::Mixed : isChecked() ? AccessibilityButtonState::On : AccessibilityButtonState::Off; |
| |
| return AccessibilityObject::checkboxOrRadioValue(); |
| } |
| |
| #if ENABLE(AX_THREAD_TEXT_APIS) |
| TextEmissionBehavior AccessibilityNodeObject::textEmissionBehavior() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return TextEmissionBehavior::None; |
| |
| if (is<HTMLParagraphElement>(*node)) { |
| // TextIterator only emits a double-newline for paragraphs conditionally (see shouldEmitExtraNewlineForNode) |
| // based on collapsed margin size. But the spec (https://html.spec.whatwg.org/multipage/dom.html#the-innertext-idl-attribute) says: |
| // > If node is a p element, then append 2 (a required line break count) at the beginning and end of items. |
| // And Chrome seems to follow the spec: https://chromium.googlesource.com/chromium/src.git/+/8ff781cd5c1aabca068247de9a3f143645e80422 |
| // WebKit tried to make this change in TextIterator, but it was reverted: |
| // https://github.com/WebKit/WebKit/commit/d206c2daf7219264b2c9b0cf0ee4cdce2450445b |
| // |
| // It's easier to unconditionally emit a double newline, so let's do that for now, since it's more spec-compliant anyways. |
| return TextEmissionBehavior::DoubleNewline; |
| } |
| |
| if (WebCore::shouldEmitNewlinesBeforeAndAfterNode(*node)) { |
| if (is<RenderView>(renderer()) || is<HTMLHtmlElement>(*node)) { |
| // Don't emit newlines for these objects. This is important because sometimes we start traversing |
| // AXTextMarkers from the root, and want to do something for every object that emits a newline, |
| // but there are no known cases where this is correct for these root elements. |
| return TextEmissionBehavior::None; |
| } |
| return TextEmissionBehavior::Newline; |
| } |
| |
| if (CheckedPtr cell = dynamicDowncast<RenderTableCell>(node->renderer()); cell && cell->nextCell()) { |
| // https://html.spec.whatwg.org/multipage/dom.html#the-innertext-idl-attribute |
| // > If node's computed value of 'display' is 'table-cell', and node's CSS box is not the last 'table-cell' |
| // > box of its enclosing 'table-row' box, then append a string containing a single U+0009 TAB code point to items. |
| return TextEmissionBehavior::Tab; |
| } |
| return TextEmissionBehavior::None; |
| } |
| #endif // ENABLE(AX_THREAD_TEXT_APIS) |
| |
| Element* AccessibilityNodeObject::anchorElement() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return nullptr; |
| |
| AXObjectCache* cache = axObjectCache(); |
| if (!cache) |
| return nullptr; |
| |
| // search up the DOM tree for an anchor element |
| // NOTE: this assumes that any non-image with an anchor is an HTMLAnchorElement |
| for ( ; node; node = node->parentNode()) { |
| if (is<HTMLAnchorElement>(*node) || (node->renderer() && cache->getOrCreate(node->renderer())->isLink())) |
| return downcast<Element>(node).get(); |
| } |
| |
| return nullptr; |
| } |
| |
| RefPtr<Element> AccessibilityNodeObject::popoverTargetElement() const |
| { |
| WeakPtr formControlElement = dynamicDowncast<HTMLFormControlElement>(node()); |
| return formControlElement ? formControlElement->popoverTargetElement() : nullptr; |
| } |
| |
| RefPtr<Element> AccessibilityNodeObject::commandForElement() const |
| { |
| RefPtr element = dynamicDowncast<HTMLButtonElement>(node()); |
| return element ? element->commandForElement() : nullptr; |
| } |
| |
| CommandType AccessibilityNodeObject::commandType() const |
| { |
| RefPtr element = dynamicDowncast<HTMLButtonElement>(node()); |
| return element ? element->commandType() : CommandType::Invalid; |
| } |
| |
| AccessibilityObject* AccessibilityNodeObject::internalLinkElement() const |
| { |
| // We don't currently support ARIA links as internal link elements, so exit early if anchorElement() is not a native HTMLAnchorElement. |
| WeakPtr anchor = dynamicDowncast<HTMLAnchorElement>(anchorElement()); |
| if (!anchor) |
| return nullptr; |
| |
| auto linkURL = anchor->href(); |
| auto fragmentIdentifier = linkURL.fragmentIdentifier(); |
| if (fragmentIdentifier.isEmpty()) |
| return nullptr; |
| |
| // Check if URL is the same as current URL |
| RefPtr document = this->document(); |
| if (!document || !equalIgnoringFragmentIdentifier(document->url(), linkURL)) |
| return nullptr; |
| |
| RefPtr linkedNode = document->findAnchor(fragmentIdentifier); |
| // The element we find may not be accessible, so find the first accessible object. |
| return firstAccessibleObjectFromNode(linkedNode.get()); |
| } |
| |
| bool AccessibilityNodeObject::toggleDetailsAncestor() |
| { |
| for (RefPtr node = this->node(); node; node = node->parentOrShadowHostNode()) { |
| if (RefPtr details = dynamicDowncast<HTMLDetailsElement>(node)) { |
| details->toggleOpen(); |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| static RefPtr<Element> nodeActionElement(Node& node) |
| { |
| auto elementName = WebCore::elementName(node); |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(node)) { |
| if (!input->isDisabledFormControl() && (input->isRadioButton() || input->isCheckbox() || input->isTextButton() || input->isFileUpload() || input->isImageButton() || input->isTextField())) |
| return input; |
| } else if (elementName == ElementName::HTML_button || elementName == ElementName::HTML_select) |
| return &downcast<Element>(node); |
| |
| // Content editable nodes should also be considered action elements, so they can accept presses. |
| if (RefPtr element = dynamicDowncast<Element>(node)) { |
| if (AccessibilityObject::contentEditableAttributeIsEnabled(*element)) |
| return element; |
| } |
| |
| return nullptr; |
| } |
| |
| static Element* nativeActionElement(Node* start) |
| { |
| if (!start) |
| return nullptr; |
| |
| // Do a deep-dive to see if any nodes should be used as the action element. |
| // We have to look at Nodes, since this method should only be called on objects that do not have children (like buttons). |
| // It solves the problem when authors put role="button" on a group and leave the actual button inside the group. |
| |
| for (RefPtr child = start->firstChild(); child; child = child->nextSibling()) { |
| if (RefPtr element = nodeActionElement(*child)) |
| return element.get(); |
| |
| if (RefPtr subChild = nativeActionElement(child.get())) |
| return subChild.get(); |
| } |
| return nullptr; |
| } |
| |
| Element* AccessibilityNodeObject::actionElement() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return nullptr; |
| |
| if (RefPtr element = nodeActionElement(*node)) |
| return element.get(); |
| |
| if (AccessibilityObject::isARIAInput(ariaRoleAttribute())) |
| return downcast<Element>(node).get(); |
| |
| switch (role()) { |
| case AccessibilityRole::Button: |
| case AccessibilityRole::PopUpButton: |
| case AccessibilityRole::ToggleButton: |
| case AccessibilityRole::Tab: |
| case AccessibilityRole::MenuItem: |
| case AccessibilityRole::MenuItemCheckbox: |
| case AccessibilityRole::MenuItemRadio: |
| case AccessibilityRole::ListItem: |
| // Check if the author is hiding the real control element inside the ARIA element. |
| if (RefPtr nativeElement = nativeActionElement(node.get())) |
| return nativeElement.get(); |
| return downcast<Element>(node).get(); |
| default: |
| break; |
| } |
| |
| if (RefPtr element = anchorElement()) |
| return element.get(); |
| |
| if (RefPtr clickableObject = this->clickableSelfOrAncestor()) |
| return clickableObject->element(); |
| |
| return nullptr; |
| } |
| |
| bool AccessibilityNodeObject::hasClickHandler() const |
| { |
| RefPtr element = this->element(); |
| return element && element->hasAnyEventListeners({ eventNames().clickEvent, eventNames().mousedownEvent, eventNames().mouseupEvent }); |
| } |
| |
| bool AccessibilityNodeObject::isDescendantOfBarrenParent() const |
| { |
| if (!m_isIgnoredFromParentData.isNull()) |
| return m_isIgnoredFromParentData.isDescendantOfBarrenParent; |
| |
| for (RefPtr object = parentObject(); object; object = object->parentObject()) { |
| if (!object->canHaveChildren()) |
| return true; |
| } |
| |
| return false; |
| } |
| |
| void AccessibilityNodeObject::alterRangeValue(StepAction stepAction) |
| { |
| if (role() != AccessibilityRole::Slider && role() != AccessibilityRole::SpinButton) |
| return; |
| |
| RefPtr element = this->element(); |
| if (!element || element->isDisabledFormControl()) |
| return; |
| |
| if (!getAttribute(stepAttr).isEmpty()) |
| changeValueByStep(stepAction); |
| else |
| changeValueByPercent(stepAction == StepAction::Increment ? 5 : -5); |
| } |
| |
| void AccessibilityNodeObject::increment() |
| { |
| UserGestureIndicator gestureIndicator(IsProcessingUserGesture::Yes, document()); |
| alterRangeValue(StepAction::Increment); |
| } |
| |
| void AccessibilityNodeObject::decrement() |
| { |
| UserGestureIndicator gestureIndicator(IsProcessingUserGesture::Yes, document()); |
| alterRangeValue(StepAction::Decrement); |
| } |
| |
| static bool dispatchSimulatedKeyboardUpDownEvent(AccessibilityObject* object, const KeyboardEvent::Init& keyInit) |
| { |
| // In case the keyboard event causes this element to be removed. |
| Ref<AccessibilityObject> protectedObject(*object); |
| |
| bool handled = false; |
| if (auto* node = object->node()) { |
| auto event = KeyboardEvent::create(eventNames().keydownEvent, keyInit, Event::IsTrusted::Yes); |
| node->dispatchEvent(event); |
| handled |= event->defaultHandled(); // The browser handled it. |
| handled |= event->defaultPrevented(); // A JavaScript event listener handled it. |
| } |
| |
| // Ensure node is still valid and wasn't removed after the keydown. |
| if (auto* node = object->node()) { |
| auto event = KeyboardEvent::create(eventNames().keyupEvent, keyInit, Event::IsTrusted::Yes); |
| node->dispatchEvent(event); |
| handled |= event->defaultHandled(); // The browser handled it. |
| handled |= event->defaultPrevented(); // A JavaScript event listener handled it. |
| } |
| return handled; |
| } |
| |
| static void InitializeLegacyKeyInitProperties(KeyboardEvent::Init &keyInit, const AccessibilityObject& object) |
| { |
| keyInit.which = keyInit.keyCode; |
| keyInit.code = keyInit.key; |
| |
| keyInit.view = object.document()->windowProxy(); |
| keyInit.cancelable = true; |
| keyInit.composed = true; |
| keyInit.bubbles = true; |
| } |
| |
| bool AccessibilityNodeObject::performDismissAction() |
| { |
| auto keyInit = KeyboardEvent::Init(); |
| keyInit.key = "Escape"_s; |
| keyInit.keyCode = 0x1b; |
| keyInit.keyIdentifier = "U+001B"_s; |
| InitializeLegacyKeyInitProperties(keyInit, *this); |
| |
| return dispatchSimulatedKeyboardUpDownEvent(this, keyInit); |
| } |
| |
| // Fire a keyboard event if we were not able to set this value natively. |
| bool AccessibilityNodeObject::postKeyboardKeysForValueChange(StepAction stepAction) |
| { |
| auto keyInit = KeyboardEvent::Init(); |
| bool isLTR = page()->userInterfaceLayoutDirection() == UserInterfaceLayoutDirection::LTR; |
| // https://w3c.github.io/aria/#spinbutton |
| // `spinbutton` elements don't have an implicit orientation, but the spec does say: |
| // > Authors SHOULD also ensure the up and down arrows on a keyboard perform the increment and decrement functions |
| // So let's force a vertical orientation for `spinbutton`s so we simulate the correct keypress (either up or down). |
| bool vertical = orientation() == AccessibilityOrientation::Vertical || role() == AccessibilityRole::SpinButton; |
| |
| // The goal is to mimic existing keyboard dispatch completely, so that this is indistinguishable from a real key press. |
| typedef enum { left = 37, up = 38, right = 39, down = 40 } keyCode; |
| keyInit.key = stepAction == StepAction::Increment ? (vertical ? "ArrowUp"_s : (isLTR ? "ArrowRight"_s : "ArrowLeft"_s)) : (vertical ? "ArrowDown"_s : (isLTR ? "ArrowLeft"_s : "ArrowRight"_s)); |
| keyInit.keyCode = stepAction == StepAction::Increment ? (vertical ? keyCode::up : (isLTR ? keyCode::right : keyCode::left)) : (vertical ? keyCode::down : (isLTR ? keyCode::left : keyCode::right)); |
| keyInit.keyIdentifier = stepAction == StepAction::Increment ? (vertical ? "Up"_s : (isLTR ? "Right"_s : "Left"_s)) : (vertical ? "Down"_s : (isLTR ? "Left"_s : "Right"_s)); |
| |
| InitializeLegacyKeyInitProperties(keyInit, *this); |
| |
| return dispatchSimulatedKeyboardUpDownEvent(this, keyInit); |
| } |
| |
| void AccessibilityNodeObject::setNodeValue(StepAction stepAction, float value) |
| { |
| bool didSet = setValue(String::number(value)); |
| |
| if (didSet) { |
| if (auto* cache = axObjectCache()) |
| cache->postNotification(this, document(), AXNotification::ValueChanged); |
| } else |
| postKeyboardKeysForValueChange(stepAction); |
| } |
| |
| void AccessibilityNodeObject::changeValueByStep(StepAction stepAction) |
| { |
| float step = stepValueForRange(); |
| float value = valueForRange(); |
| |
| value += stepAction == StepAction::Increment ? step : -step; |
| setNodeValue(stepAction, value); |
| } |
| |
| void AccessibilityNodeObject::changeValueByPercent(float percentChange) |
| { |
| if (!percentChange) |
| return; |
| |
| float range = maxValueForRange() - minValueForRange(); |
| float step = range * (percentChange / 100); |
| float value = valueForRange(); |
| |
| // Make sure the specified percent will cause a change of one integer step or larger. |
| if (std::abs(step) < 1) |
| step = std::abs(percentChange) * (1 / percentChange); |
| |
| value += step; |
| setNodeValue(percentChange > 0 ? StepAction::Increment : StepAction::Decrement, value); |
| } |
| |
| bool AccessibilityNodeObject::elementAttributeValue(const QualifiedName& attributeName) const |
| { |
| return equalLettersIgnoringASCIICase(getAttribute(attributeName), "true"_s); |
| } |
| |
| bool AccessibilityNodeObject::liveRegionAtomic() const |
| { |
| const auto& atomic = getAttribute(aria_atomicAttr); |
| if (equalLettersIgnoringASCIICase(atomic, "true"_s)) |
| return true; |
| if (equalLettersIgnoringASCIICase(atomic, "false"_s)) |
| return false; |
| |
| // WAI-ARIA "alert" and "status" roles have an implicit aria-atomic value of true. |
| switch (role()) { |
| case AccessibilityRole::ApplicationAlert: |
| case AccessibilityRole::ApplicationStatus: |
| return true; |
| default: |
| return false; |
| } |
| } |
| |
| // This function is like a cross-platform version of - (WebCoreTextMarkerRange*)textMarkerRange. It returns |
| // a Range that we can convert to a WebCoreTextMarkerRange in the Obj-C file |
| VisiblePositionRange AccessibilityNodeObject::visiblePositionRange() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return VisiblePositionRange(); |
| |
| VisiblePosition startPos = firstPositionInOrBeforeNode(node.get()); |
| VisiblePosition endPos = lastPositionInOrAfterNode(node.get()); |
| |
| // the VisiblePositions are equal for nodes like buttons, so adjust for that |
| // FIXME: Really? [button, 0] and [button, 1] are distinct (before and after the button) |
| // I expect this code is only hit for things like empty divs? In which case I don't think |
| // the behavior is correct here -- eseidel |
| if (startPos == endPos) { |
| endPos = endPos.next(); |
| if (endPos.isNull()) |
| endPos = startPos; |
| } |
| |
| return { WTFMove(startPos), WTFMove(endPos) }; |
| } |
| |
| VisiblePositionRange AccessibilityNodeObject::selectedVisiblePositionRange() const |
| { |
| RefPtr document = this->document(); |
| if (RefPtr localFrame = document ? document->frame() : nullptr) { |
| if (auto selection = localFrame->selection().selection(); !selection.isNone()) |
| return selection; |
| } |
| return { }; |
| } |
| |
| int AccessibilityNodeObject::indexForVisiblePosition(const VisiblePosition& position) const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return 0; |
| // We need to consider replaced elements for GTK, as they will be |
| // presented with the 'object replacement character' (0xFFFC). |
| TextIteratorBehaviors behaviors; |
| #if USE(ATSPI) |
| behaviors.add(TextIteratorBehavior::EmitsObjectReplacementCharacters); |
| #endif |
| return WebCore::indexForVisiblePosition(*node, position, behaviors); |
| } |
| |
| VisiblePosition AccessibilityNodeObject::visiblePositionForIndex(int index) const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return { }; |
| #if USE(ATSPI) |
| // We need to consider replaced elements for GTK, as they will be presented with the 'object replacement character' (0xFFFC). |
| return WebCore::visiblePositionForIndex(index, node.get(), TextIteratorBehavior::EmitsObjectReplacementCharacters); |
| #else |
| return visiblePositionForIndexUsingCharacterIterator(*node, index); |
| #endif |
| } |
| |
| VisiblePositionRange AccessibilityNodeObject::visiblePositionRangeForLine(unsigned lineCount) const |
| { |
| if (!lineCount) |
| return { }; |
| |
| RefPtr document = this->document(); |
| auto* renderView = document ? document->renderView() : nullptr; |
| if (!renderView) |
| return { }; |
| |
| // iterate over the lines |
| // FIXME: This is wrong when lineNumber is lineCount+1, because nextLinePosition takes you to the last offset of the last line. |
| VisiblePosition position = renderView->positionForPoint(IntPoint(), HitTestSource::User, nullptr); |
| while (--lineCount) { |
| auto previousLinePosition = position; |
| position = nextLinePosition(position, 0); |
| if (position.isNull() || position == previousLinePosition) |
| return VisiblePositionRange(); |
| } |
| |
| // make a caret selection for the marker position, then extend it to the line |
| // NOTE: Ignores results of sel.modify because it returns false when starting at an empty line. |
| // The resulting selection in that case will be a caret at position. |
| FrameSelection selection; |
| selection.setSelection(position); |
| selection.modify(FrameSelection::Alteration::Extend, SelectionDirection::Right, TextGranularity::LineBoundary); |
| return selection.selection(); |
| } |
| |
| bool AccessibilityNodeObject::isGenericFocusableElement() const |
| { |
| if (!canSetFocusAttribute()) |
| return false; |
| |
| // If it's a control, it's not generic. |
| if (isControl()) |
| return false; |
| |
| auto role = this->role(); |
| if (role == AccessibilityRole::Video || role == AccessibilityRole::Audio) |
| return false; |
| |
| // If it has an aria role, it's not generic. |
| if (m_ariaRole != AccessibilityRole::Unknown) |
| return false; |
| |
| // If the content editable attribute is set on this element, that's the reason |
| // it's focusable, and existing logic should handle this case already - so it's not a |
| // generic focusable element. |
| |
| if (hasContentEditableAttributeSet()) |
| return false; |
| |
| // The web area and body element are both focusable, but existing logic handles these |
| // cases already, so we don't need to include them here. |
| if (role == AccessibilityRole::WebArea) |
| return false; |
| if (elementName() == ElementName::HTML_body) |
| return false; |
| |
| // An SVG root is focusable by default, but it's probably not interactive, so don't |
| // include it. It can still be made accessible by giving it an ARIA role. |
| if (role == AccessibilityRole::SVGRoot) |
| return false; |
| |
| return true; |
| } |
| |
| AccessibilityObject* AccessibilityNodeObject::controlForLabelElement() const |
| { |
| RefPtr labelElement = labelElementContainer(); |
| return labelElement ? axObjectCache()->getOrCreate(Accessibility::controlForLabelElement(*labelElement).get()) : nullptr; |
| } |
| |
| String AccessibilityNodeObject::ariaAccessibilityDescription() const |
| { |
| String ariaLabeledBy = ariaLabeledByAttribute(); |
| if (!ariaLabeledBy.isEmpty()) |
| return ariaLabeledBy; |
| |
| auto ariaLabel = getAttributeTrimmed(aria_labelAttr); |
| if (!ariaLabel.isEmpty()) |
| return ariaLabel; |
| |
| return String(); |
| } |
| |
| AccessibilityObject* AccessibilityNodeObject::captionForFigure() const |
| { |
| if (!isFigureElement()) |
| return nullptr; |
| |
| AXObjectCache* cache = axObjectCache(); |
| if (!cache) |
| return nullptr; |
| |
| RefPtr node = this->node(); |
| for (RefPtr child = node->firstChild(); child; child = child->nextSibling()) { |
| if (WebCore::elementName(*child) == ElementName::HTML_figcaption) |
| return cache->getOrCreate(*child); |
| } |
| return nullptr; |
| } |
| |
| bool AccessibilityNodeObject::usesAltForTextComputation() const |
| { |
| bool usesAltTag = isImage() || isInputImage() || isNativeImage() || isCanvas() || elementName() == ElementName::HTML_img; |
| #if ENABLE(MODEL_ELEMENT) |
| usesAltTag |= isModel(); |
| #endif |
| return usesAltTag; |
| } |
| |
| bool AccessibilityNodeObject::isLabelable() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return false; |
| return is<HTMLInputElement>(*node) || isControl() || isProgressIndicator() || isMeter(); |
| } |
| |
| String AccessibilityNodeObject::textAsLabelFor(const AccessibilityObject& labeledObject) const |
| { |
| auto labelAttribute = getAttributeTrimmed(aria_labelAttr); |
| if (!labelAttribute.isEmpty()) |
| return labelAttribute; |
| |
| labelAttribute = altTextFromAttributeOrStyle(); |
| if (!labelAttribute.isEmpty()) |
| return labelAttribute; |
| |
| labelAttribute = getAttribute(titleAttr); |
| if (!labelAttribute.isEmpty()) |
| return labelAttribute; |
| |
| if (isAccessibilityLabelInstance()) { |
| StringBuilder builder; |
| for (const auto& child : const_cast<AccessibilityNodeObject*>(this)->unignoredChildren()) { |
| if (child.ptr() == &labeledObject) |
| continue; |
| |
| if (child->isListBox()) { |
| auto selectedChildren = child->selectedChildren(); |
| for (const auto& selectedGrandChild : selectedChildren) |
| appendNameToStringBuilder(builder, accessibleNameForNode(*selectedGrandChild->node())); |
| continue; |
| } |
| |
| if (child->isComboBox()) { |
| appendNameToStringBuilder(builder, child->stringValue()); |
| continue; |
| } |
| |
| if (child->isTextControl()) { |
| appendNameToStringBuilder(builder, child->text()); |
| continue; |
| } |
| |
| if (child->isSlider() || child->isSpinButton()) { |
| appendNameToStringBuilder(builder, String::number(child->valueForRange())); |
| continue; |
| } |
| |
| appendNameToStringBuilder(builder, child->textUnderElement()); |
| } |
| if (builder.length()) |
| return builder.toString().trim(isASCIIWhitespace).simplifyWhiteSpace(isHTMLSpaceButNotLineBreak); |
| } |
| |
| String text = this->text(); |
| if (!text.isEmpty()) |
| return text; |
| return textUnderElement(); |
| } |
| |
| String AccessibilityNodeObject::textForLabelElements(Vector<Ref<HTMLElement>>&& labelElements) const |
| { |
| // https://www.w3.org/TR/html-aam-1.0/#input-type-text-input-type-password-input-type-number-input-type-search-input-type-tel-input-type-email-input-type-url-and-textarea-element-accessible-name-computation |
| // "...if more than one label is associated; concatenate by DOM order, delimited by spaces." |
| StringBuilder result; |
| |
| WeakPtr cache = axObjectCache(); |
| for (auto& labelElement : labelElements) { |
| RefPtr label = cache ? cache->getOrCreate(labelElement.ptr()) : nullptr; |
| if (!label) |
| continue; |
| |
| if (label.get() == this) { |
| // This object labels itself, so use its textAsLabel. |
| appendNameToStringBuilder(result, textAsLabelFor(*this)); |
| continue; |
| } |
| |
| auto ariaLabeledBy = label->ariaLabeledByAttribute(); |
| if (!ariaLabeledBy.isEmpty()) |
| appendNameToStringBuilder(result, WTFMove(ariaLabeledBy)); |
| #if PLATFORM(COCOA) |
| else if (RefPtr axLabel = dynamicDowncast<AccessibilityLabel>(*label)) |
| appendNameToStringBuilder(result, axLabel->textAsLabelFor(*this)); |
| #endif |
| else |
| appendNameToStringBuilder(result, accessibleNameForNode(labelElement.get())); |
| } |
| |
| return result.toString(); |
| } |
| |
| HTMLLabelElement* AccessibilityNodeObject::labelElementContainer() const |
| { |
| // The control element should not be considered part of the label. |
| if (isControl()) |
| return nullptr; |
| |
| // Find an ancestor label element. |
| for (auto* parentNode = node(); parentNode; parentNode = parentNode->parentNode()) { |
| if (auto* label = dynamicDowncast<HTMLLabelElement>(*parentNode)) |
| return label; |
| } |
| return nullptr; |
| } |
| |
| void AccessibilityNodeObject::labelText(Vector<AccessibilityText>& textOrder) const |
| { |
| RefPtr element = this->element(); |
| if (!element) |
| return; |
| |
| Vector<Ref<HTMLElement>> elementLabels; |
| auto axLabels = labeledByObjects(); |
| if (axLabels.size()) { |
| elementLabels.appendVector(WTF::compactMap(axLabels, [] (auto& axLabel) { |
| return RefPtr { dynamicDowncast<HTMLElement>(axLabel->element()) }; |
| })); |
| } |
| if (!elementLabels.size()) |
| elementLabels = Accessibility::labelsForElement(element.get()); |
| |
| String label = textForLabelElements(WTFMove(elementLabels)); |
| if (!label.isEmpty()) { |
| textOrder.append({ WTFMove(label), isMeter() ? AccessibilityTextSource::Alternative : AccessibilityTextSource::LabelByElement }); |
| return; |
| } |
| |
| auto ariaLabel = getAttributeTrimmed(aria_labelAttr); |
| if (!ariaLabel.isEmpty()) { |
| textOrder.append({ WTFMove(ariaLabel), AccessibilityTextSource::LabelByElement }); |
| return; |
| } |
| } |
| |
| bool AccessibilityNodeObject::hasTextAlternative() const |
| { |
| // ARIA: section 2A, bullet #3 says if aria-labeledby or aria-label appears, it should |
| // override the "label" element association. |
| return ariaAccessibilityDescription().length(); |
| } |
| |
| void AccessibilityNodeObject::alternativeText(Vector<AccessibilityText>& textOrder) const |
| { |
| if (isWebArea()) { |
| String webAreaText = alternativeTextForWebArea(); |
| if (!webAreaText.isEmpty()) |
| textOrder.append(AccessibilityText(WTFMove(webAreaText), AccessibilityTextSource::Alternative)); |
| return; |
| } |
| |
| ariaLabeledByText(textOrder); |
| |
| bool hasValidAriaLabel = false; |
| { |
| // Scoped since we potentially move |ariaLabel| here. The scope prevents accidental use-after-move later. |
| auto ariaLabel = getAttributeTrimmed(aria_labelAttr); |
| if (!ariaLabel.isEmpty()) { |
| hasValidAriaLabel = true; |
| textOrder.append(AccessibilityText(WTFMove(ariaLabel), AccessibilityTextSource::Alternative)); |
| } |
| } |
| |
| if (usesAltForTextComputation()) { |
| if (auto* renderImage = dynamicDowncast<RenderImage>(renderer())) { |
| String renderAltText = renderImage->altText(); |
| |
| // RenderImage will return title as a fallback from altText, but we don't want title here because we consider that in helpText. |
| if (!renderAltText.isEmpty() && renderAltText != getAttribute(titleAttr)) { |
| textOrder.append(AccessibilityText(WTFMove(renderAltText), AccessibilityTextSource::Alternative)); |
| return; |
| } |
| } |
| // Images should use alt as long as the attribute is present, even if empty. |
| // Otherwise, it should fallback to other methods, like the title attribute. |
| if (String alt = altTextFromAttributeOrStyle(); !alt.isNull()) |
| textOrder.append(AccessibilityText(WTFMove(alt), AccessibilityTextSource::Alternative)); |
| } |
| |
| RefPtr node = this->node(); |
| if (!node) |
| return; |
| |
| auto objectCache = axObjectCache(); |
| // The fieldset element derives its alternative text from the first associated legend element if one is available. |
| if (RefPtr fieldset = dynamicDowncast<HTMLFieldSetElement>(*node); fieldset && objectCache) { |
| RefPtr object = objectCache->getOrCreate(fieldset->legend()); |
| if (object && !object->isHidden()) |
| textOrder.append(AccessibilityText(accessibleNameForNode(*object->node()), AccessibilityTextSource::Alternative)); |
| } |
| |
| if (RefPtr image = dynamicDowncast<HTMLImageElement>(*node)) { |
| // https://github.com/w3c/aria/pull/2224 |
| // Per html-aam, <img> elements that are unlabeled (e.g., alt attribute, ARIA, title) derive accname |
| // from an ancestor figure's <figcaption> if and only if the <figure> does not contain other flow content (besides the <figcaption>). |
| const AtomString& alt = image->attributeWithoutSynchronization(altAttr); |
| |
| if (alt.isEmpty() && image->attributeWithoutSynchronization(titleAttr).isEmpty()) { |
| for (RefPtr ancestor = node->parentNode(); ancestor; ancestor = ancestor->parentNode()) { |
| if (auto* figure = dynamicDowncast<HTMLElement>(ancestor.get()); figure && figure->hasTagName(figureTag)) { |
| bool figureHasFlowContent = false; |
| // Iterate over the direct children of the <img>'s ancestor <figure> for any common |
| // flow content, including non-whitespace text nodes. |
| for (RefPtr figureNodeChild = figure->firstChild(); figureNodeChild; figureNodeChild = figureNodeChild->nextSibling()) { |
| if (isFlowContent(*figureNodeChild)) { |
| figureHasFlowContent = true; |
| break; |
| } |
| } |
| // If no flow content is present in the <figure>, the <img> derives accname from its <figcaption>. |
| if (!figureHasFlowContent) { |
| RefPtr figureObject = objectCache ? objectCache->getOrCreate(*figure) : nullptr; |
| RefPtr caption = figureObject && figureObject->isFigureElement() ? downcast<AccessibilityNodeObject>(figureObject)->captionForFigure() : nullptr; |
| if (caption && !caption->isHidden()) { |
| RefPtr captionNode = caption->node(); |
| if (String captionAccname = captionNode ? accessibleNameForNode(*captionNode) : emptyString(); !captionAccname.isEmpty()) |
| textOrder.append(AccessibilityText(WTFMove(captionAccname), AccessibilityTextSource::Alternative)); |
| } |
| } |
| break; |
| } |
| } |
| } |
| } |
| |
| // Tree items missing a label are labeled by all child elements. |
| if (isTreeItem() && !hasValidAriaLabel && ariaLabeledByAttribute().isEmpty()) |
| textOrder.append(AccessibilityText(accessibleNameForNode(*node), AccessibilityTextSource::Alternative)); |
| |
| if (accessibleNameDerivesFromHeading()) { |
| CheckedPtr cache = axObjectCache(); |
| // Where an element supports nameFrom: heading and no nameFrom: content/author is supplied, its accname may be |
| // derived from the first descendant node that is a heading (depth-first search, preorder traversal). |
| if (RefPtr containerNode = dynamicDowncast<ContainerNode>(node); containerNode && cache) { |
| for (Ref element : descendantsOfType<Element>(*containerNode)) { |
| if (RefPtr descendantObject = cache->getOrCreate(element.get()); descendantObject && descendantObject->isHeading()) { |
| TextUnderElementMode mode; |
| mode.includeFocusableContent = true; |
| String nameFromHeading = descendantObject->textUnderElement(mode); |
| if (!nameFromHeading.isEmpty()) |
| textOrder.append(AccessibilityText(nameFromHeading, AccessibilityTextSource::Heading)); |
| } |
| } |
| } |
| } |
| |
| #if ENABLE(MATHML) |
| if (node->isMathMLElement()) |
| textOrder.append(AccessibilityText(getAttribute(MathMLNames::alttextAttr), AccessibilityTextSource::Alternative)); |
| #endif |
| |
| if (CheckedPtr style = this->style()) { |
| String altText = style->altFromContent(); |
| if (!altText.isEmpty()) |
| textOrder.append(AccessibilityText(WTFMove(altText), AccessibilityTextSource::Alternative)); |
| } |
| } |
| |
| void AccessibilityNodeObject::visibleText(Vector<AccessibilityText>& textOrder) const |
| { |
| WeakPtr node = this->node(); |
| if (!node) |
| return; |
| |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(*node); input && input->isTextButton()) { |
| textOrder.append(AccessibilityText(input->valueWithDefault(), AccessibilityTextSource::Visible)); |
| return; |
| } |
| |
| // If this node isn't rendered, there's no inner text we can extract from a select element. |
| if (!isAccessibilityRenderObject() && WebCore::elementName(*node) == ElementName::HTML_select) |
| return; |
| |
| if (dependsOnTextUnderElement()) { |
| TextUnderElementMode mode; |
| |
| // Headings often include links as direct children. Those links need to be included in text under element. |
| if (isHeading()) |
| mode.includeFocusableContent = true; |
| |
| String text = textUnderElement(mode); |
| if (!text.isEmpty()) |
| textOrder.append(AccessibilityText(WTFMove(text), AccessibilityTextSource::Children)); |
| } |
| } |
| |
| void AccessibilityNodeObject::helpText(Vector<AccessibilityText>& textOrder) const |
| { |
| const AtomString& ariaHelp = getAttribute(aria_helpAttr); |
| if (!ariaHelp.isEmpty()) [[unlikely]] |
| textOrder.append(AccessibilityText(ariaHelp, AccessibilityTextSource::Help)); |
| |
| #if !PLATFORM(COCOA) |
| String describedBy = ariaDescribedByAttribute(); |
| if (!describedBy.isEmpty()) |
| textOrder.append(AccessibilityText(describedBy, AccessibilityTextSource::Summary)); |
| #endif |
| |
| if (isControl()) { |
| // For controls, use their fieldset parent's described-by text if available. |
| auto matchFunc = [] (const AccessibilityObject& object) { |
| return object.isFieldset() && !object.ariaDescribedByAttribute().isEmpty(); |
| }; |
| if (RefPtr parent = Accessibility::findAncestor<AccessibilityObject>(*this, false, WTFMove(matchFunc))) |
| textOrder.append(AccessibilityText(parent->ariaDescribedByAttribute(), AccessibilityTextSource::Summary)); |
| } |
| |
| // Summary attribute used as help text on tables. |
| const AtomString& summary = getAttribute(summaryAttr); |
| if (!summary.isEmpty()) |
| textOrder.append(AccessibilityText(summary, AccessibilityTextSource::Summary)); |
| |
| // The title attribute should be used as help text unless it is already being used as descriptive text. |
| // However, when the title attribute is the only text alternative provided, it may be exposed as the |
| // descriptive text. This is problematic in the case of meters because the HTML spec suggests authors |
| // can expose units through this attribute. Therefore, if the element is a meter, change its source |
| // type to AccessibilityTextSource::Help. |
| const AtomString& title = getAttribute(titleAttr); |
| if (!title.isEmpty()) { |
| if (!isMeter() && !roleIgnoresTitle()) |
| textOrder.append(AccessibilityText(title, AccessibilityTextSource::TitleTag)); |
| else |
| textOrder.append(AccessibilityText(title, AccessibilityTextSource::Help)); |
| } |
| } |
| |
| void AccessibilityNodeObject::accessibilityText(Vector<AccessibilityText>& textOrder) const |
| { |
| labelText(textOrder); |
| alternativeText(textOrder); |
| visibleText(textOrder); |
| helpText(textOrder); |
| |
| String placeholder = placeholderValue(); |
| if (!placeholder.isEmpty()) |
| textOrder.append(AccessibilityText(WTFMove(placeholder), AccessibilityTextSource::Placeholder)); |
| } |
| |
| void AccessibilityNodeObject::ariaLabeledByText(Vector<AccessibilityText>& textOrder) const |
| { |
| String ariaLabeledBy = ariaLabeledByAttribute(); |
| if (!ariaLabeledBy.isEmpty()) |
| textOrder.append(AccessibilityText(WTFMove(ariaLabeledBy), AccessibilityTextSource::Alternative)); |
| } |
| |
| String AccessibilityNodeObject::alternativeTextForWebArea() const |
| { |
| // The WebArea description should follow this order: |
| // aria-label on the <html> |
| // title on the <html> |
| // <title> inside the <head> (of it was set through JS) |
| // name on the <html> |
| // For iframes: |
| // aria-label on the <iframe> |
| // title on the <iframe> |
| // name on the <iframe> |
| |
| RefPtr document = this->document(); |
| if (!document) |
| return String(); |
| |
| // Check if the HTML element has an aria-label for the webpage. |
| if (RefPtr documentElement = document->documentElement()) { |
| const AtomString& ariaLabel = documentElement->attributeWithoutSynchronization(aria_labelAttr); |
| if (!ariaLabel.isEmpty()) |
| return ariaLabel; |
| } |
| |
| if (RefPtr owner = document->ownerElement()) { |
| auto elementName = owner->elementName(); |
| if (elementName == ElementName::HTML_frame || elementName == ElementName::HTML_iframe) { |
| const AtomString& title = owner->attributeWithoutSynchronization(titleAttr); |
| if (!title.isEmpty()) |
| return title; |
| } |
| return owner->getNameAttribute(); |
| } |
| |
| String documentTitle = document->title(); |
| if (!documentTitle.isEmpty()) |
| return documentTitle; |
| |
| if (RefPtr body = document->bodyOrFrameset()) |
| return body->getNameAttribute(); |
| |
| return String(); |
| } |
| |
| String AccessibilityNodeObject::description() const |
| { |
| // Static text should not have a description, it should only have a stringValue. |
| if (role() == AccessibilityRole::StaticText) |
| return { }; |
| |
| String ariaDescription = ariaAccessibilityDescription(); |
| if (!ariaDescription.isEmpty()) |
| return ariaDescription; |
| |
| if (usesAltForTextComputation()) { |
| // Images should use alt as long as the attribute is present, even if empty. |
| // Otherwise, it should fallback to other methods, like the title attribute. |
| if (String alt = altTextFromAttributeOrStyle(); !alt.isNull()) |
| return alt; |
| } |
| |
| #if ENABLE(MATHML) |
| if (is<MathMLElement>(node())) |
| return getAttribute(MathMLNames::alttextAttr); |
| #endif |
| |
| // An element's descriptive text is comprised of title() (what's visible on the screen) and description() (other descriptive text). |
| // Both are used to generate what a screen reader speaks. |
| // If this point is reached (i.e. there's no accessibilityDescription) and there's no title(), we should fallback to using the title attribute. |
| // The title attribute is normally used as help text (because it is a tooltip), but if there is nothing else available, this should be used (according to ARIA). |
| // https://bugs.webkit.org/show_bug.cgi?id=170475: An exception is when the element is semantically unimportant. In those cases, title text should remain as help text. |
| if (!roleIgnoresTitle()) { |
| // title() can be an expensive operation because it can invoke textUnderElement for all descendants. Thus call it last. |
| auto titleAttribute = getAttribute(titleAttr); |
| if (!titleAttribute.isEmpty() && title().isEmpty()) |
| return titleAttribute; |
| } |
| |
| return { }; |
| } |
| |
| // Returns whether the role was not intended to play a semantically meaningful part of the |
| // accessibility hierarchy. This applies to generic groups like <div>'s with no role value set. |
| bool AccessibilityNodeObject::roleIgnoresTitle() const |
| { |
| if (ariaRoleAttribute() != AccessibilityRole::Unknown) |
| return false; |
| |
| switch (role()) { |
| case AccessibilityRole::Generic: |
| case AccessibilityRole::Unknown: |
| return true; |
| default: |
| return false; |
| } |
| } |
| |
| String AccessibilityNodeObject::helpText() const |
| { |
| WeakPtr node = this->node(); |
| if (!node) |
| return { }; |
| |
| const auto& ariaHelp = getAttribute(aria_helpAttr); |
| if (!ariaHelp.isEmpty()) [[unlikely]] |
| return ariaHelp; |
| |
| String describedBy = ariaDescribedByAttribute(); |
| if (!describedBy.isEmpty()) |
| return describedBy; |
| |
| String description = this->description(); |
| for (RefPtr ancestor = node.get(); ancestor; ancestor = ancestor->parentNode()) { |
| if (RefPtr element = dynamicDowncast<HTMLElement>(ancestor)) { |
| const auto& summary = element->getAttribute(summaryAttr); |
| if (!summary.isEmpty()) |
| return summary; |
| |
| // The title attribute should be used as help text unless it is already being used as descriptive text. |
| const auto& title = element->getAttribute(titleAttr); |
| if (!title.isEmpty() && description != title) |
| return title; |
| } |
| |
| auto* cache = axObjectCache(); |
| if (!cache) |
| return { }; |
| |
| // Only take help text from an ancestor element if its a group or an unknown role. If help was |
| // added to those kinds of elements, it is likely it was meant for a child element. |
| if (RefPtr axAncestor = cache->getOrCreate(*ancestor)) { |
| if (!axAncestor->isGroup() && axAncestor->role() != AccessibilityRole::Unknown) |
| break; |
| } |
| } |
| |
| return { }; |
| } |
| |
| URL AccessibilityNodeObject::url() const |
| { |
| RefPtr node = this->node(); |
| if (RefPtr anchor = dynamicDowncast<HTMLAnchorElement>(node); anchor && isLink()) |
| return anchor->href(); |
| |
| if (RefPtr image = dynamicDowncast<HTMLImageElement>(node); image && isImage()) |
| return image->getURLAttribute(srcAttr); |
| |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(node); input && isInputImage()) |
| return input->getURLAttribute(srcAttr); |
| |
| #if ENABLE(VIDEO) |
| if (RefPtr video = dynamicDowncast<HTMLVideoElement>(node); video && isVideo()) |
| return video->currentSrc(); |
| #endif |
| |
| return URL(); |
| } |
| |
| void AccessibilityNodeObject::setIsExpanded(bool expand) |
| { |
| if (RefPtr details = dynamicDowncast<HTMLDetailsElement>(node())) { |
| if (expand != details->hasAttribute(openAttr)) |
| details->toggleOpen(); |
| } |
| } |
| |
| // When building the textUnderElement for an object, determine whether or not |
| // we should include the inner text of this given descendant object or skip it. |
| static bool shouldUseAccessibilityObjectInnerText(AccessibilityObject& object, TextUnderElementMode mode) |
| { |
| #if USE(ATSPI) |
| // Only ATSPI ever sets IncludeAllChildren. |
| // Do not use any heuristic if we are explicitly asking to include all the children. |
| if (mode.childrenInclusion == TextUnderElementMode::Children::IncludeAllChildren) |
| return true; |
| #endif // USE(ATSPI) |
| |
| // Consider this hypothetical example: |
| // <div tabindex=0> |
| // <h2> |
| // Table of contents |
| // </h2> |
| // <a href="#start">Jump to start of book</a> |
| // <ul> |
| // <li><a href="#1">Chapter 1</a></li> |
| // <li><a href="#1">Chapter 2</a></li> |
| // </ul> |
| // </div> |
| // |
| // The goal is to return a reasonable title for the outer container div, because |
| // it's focusable - but without making its title be the full inner text, which is |
| // quite long. As a heuristic, skip links, controls, and elements that are usually |
| // containers with lots of children. |
| |
| // ARIA states that certain elements are not allowed to expose their children content for name calculation. |
| if (mode.childrenInclusion == TextUnderElementMode::Children::IncludeNameFromContentsChildren |
| && !object.accessibleNameDerivesFromContent()) |
| return false; |
| |
| if (equalLettersIgnoringASCIICase(object.getAttribute(aria_hiddenAttr), "true"_s)) |
| return false; |
| |
| // If something doesn't expose any children, then we can always take the inner text content. |
| // This is what we want when someone puts an <a> inside a <button> for example. |
| if (object.isDescendantOfBarrenParent()) |
| return true; |
| |
| // Skip focusable children, so we don't include the text of links and controls. |
| if (object.canSetFocusAttribute() && !mode.includeFocusableContent) |
| return false; |
| |
| // Skip big container elements like lists, tables, etc. |
| if (is<AccessibilityList>(object)) |
| return false; |
| |
| if (auto* table = dynamicDowncast<AccessibilityTable>(object); table && table->isExposable()) |
| return false; |
| |
| if (object.isTree() || object.isCanvas()) |
| return false; |
| |
| #if ENABLE(MODEL_ELEMENT) |
| if (object.isModel()) |
| return false; |
| #endif |
| |
| return true; |
| } |
| |
| static void appendNameToStringBuilder(StringBuilder& builder, String&& text, bool prependSpace) |
| { |
| if (text.isEmpty()) |
| return; |
| |
| if (prependSpace && !isHTMLLineBreak(text[0]) && builder.length() && !isHTMLLineBreak(builder[builder.length() - 1])) |
| builder.append(' '); |
| builder.append(WTFMove(text)); |
| } |
| |
| |
| static bool displayTypeNeedsSpace(DisplayType type) |
| { |
| return type == DisplayType::Block |
| || type == DisplayType::InlineBlock |
| || type == DisplayType::InlineFlex |
| || type == DisplayType::InlineGrid |
| || type == DisplayType::InlineTable |
| || type == DisplayType::TableCell; |
| } |
| |
| static bool needsSpaceFromDisplay(AccessibilityObject& axObject) |
| { |
| CheckedPtr renderer = axObject.renderer(); |
| if (is<RenderText>(renderer)) { |
| // Never add a space for RenderTexts. They are inherently inline, but take their parent's style, which may |
| // be block, erroneously adding a space. |
| return false; |
| } |
| |
| if (auto* style = renderer ? &downcast<RenderElement>(*renderer).style() : axObject.style()) |
| return displayTypeNeedsSpace(style->display()); |
| return false; |
| } |
| |
| static bool shouldPrependSpace(AccessibilityObject& object, AccessibilityObject* previousObject) |
| { |
| return needsSpaceFromDisplay(object) |
| || (previousObject && needsSpaceFromDisplay(*previousObject)) |
| || object.isControl() |
| || (previousObject && previousObject->isControl()); |
| } |
| |
| String AccessibilityNodeObject::textUnderElement(TextUnderElementMode mode) const |
| { |
| RefPtr node = this->node(); |
| if (auto* text = dynamicDowncast<Text>(node.get())) |
| return !mode.isHidden() ? text->data() : emptyString(); |
| |
| const auto* style = this->style(); |
| mode.inHiddenSubtree = WebCore::isRenderHidden(style); |
| // The Accname specification states that if the current node is hidden, and not directly |
| // referenced by aria-labelledby or aria-describedby, and is not a host language text |
| // alternative, the empty string should be returned. |
| if (mode.isHidden() && node && !ancestorsOfType<HTMLCanvasElement>(*node).first()) { |
| if (!labelForObjects().isEmpty() || !descriptionForObjects().isEmpty()) { |
| // This object is a hidden label or description for another object, so ignore hidden states for our |
| // subtree text under element traversals too. |
| // |
| // https://w3c.github.io/accname/#comp_labelledby |
| // "The result of LabelledBy Recursion in combination with Hidden Not Referenced means that user |
| // agents MUST include all nodes in the subtree as part of the accessible name or accessible |
| // description, when the node referenced by aria-labelledby or aria-describedby is hidden." |
| mode.considerHiddenState = false; |
| } else if (style && style->display() == DisplayType::None) { |
| // Unlike visibility:visible + visiblity:visible where the latter can override the former in a subtree, |
| // display:none guarantees nothing within will be rendered, so we can exit early. |
| return { }; |
| } |
| } |
| |
| StringBuilder builder; |
| RefPtr<AccessibilityObject> previous; |
| bool previousRequiresSpace = false; |
| auto appendTextUnderElement = [&] (auto& object) { |
| // We don't want to trim whitespace in these intermediate calls to textUnderElement, as doing so will wipe out |
| // spaces we need to build the string properly. If anything (depending on the original `mode`), we will trim |
| // whitespace at the very end. |
| SetForScope resetModeTrim(mode.trimWhitespace, TrimWhitespace::No); |
| |
| auto childText = object.textUnderElement(mode); |
| if (childText.length()) { |
| appendNameToStringBuilder(builder, WTFMove(childText), previousRequiresSpace || shouldPrependSpace(object, previous.get())); |
| previousRequiresSpace = false; |
| } |
| }; |
| |
| auto childIterator = AXChildIterator(*this); |
| for (auto child = childIterator.begin(); child != childIterator.end(); previous = child.ptr(), ++child) { |
| if (mode.ignoredChildNode && child->node() == mode.ignoredChildNode) |
| continue; |
| |
| if (mode.isHidden()) { |
| // If we are within a hidden context, don't add any text for this node. Instead, fan out downwards |
| // to search for un-hidden nodes (e.g. visibility:visible nodes within a visibility:hidden ancestor). |
| appendTextUnderElement(*child); |
| continue; |
| } |
| |
| bool shouldDeriveNameFromAuthor = (mode.childrenInclusion == TextUnderElementMode::Children::IncludeNameFromContentsChildren && !child->accessibleNameDerivesFromContent()); |
| if (shouldDeriveNameFromAuthor) { |
| auto nameForNode = accessibleNameForNode(*child->node()); |
| bool nameIsEmpty = nameForNode.isEmpty(); |
| appendNameToStringBuilder(builder, WTFMove(nameForNode)); |
| // Separate author-provided text with a space. |
| previousRequiresSpace = previousRequiresSpace || !nameIsEmpty; |
| continue; |
| } |
| |
| if (!shouldUseAccessibilityObjectInnerText(*child, mode)) |
| continue; |
| |
| if (RefPtr accessibilityNodeObject = dynamicDowncast<AccessibilityNodeObject>(*child)) { |
| // We should ignore the child if it's labeled by this node. |
| // This could happen when this node labels multiple child nodes and we didn't |
| // skip in the above ignoredChildNode check. |
| auto labeledByElements = accessibilityNodeObject->ariaLabeledByElements(); |
| if (labeledByElements.containsIf([&](auto& element) { return element.ptr() == node; })) |
| continue; |
| |
| Vector<AccessibilityText> textOrder; |
| accessibilityNodeObject->alternativeText(textOrder); |
| if (textOrder.size() > 0 && textOrder[0].text.length()) { |
| appendNameToStringBuilder(builder, WTFMove(textOrder[0].text)); |
| // Alternative text (e.g. from aria-label, aria-labelledby, alt, etc) requires space separation. |
| previousRequiresSpace = true; |
| continue; |
| } |
| } |
| |
| appendTextUnderElement(*child); |
| } |
| |
| auto result = builder.toString(); |
| return mode.trimWhitespace == TrimWhitespace::Yes |
| ? result.trim(isASCIIWhitespace).simplifyWhiteSpace(isHTMLSpaceButNotLineBreak) |
| : result; |
| } |
| |
| String AccessibilityNodeObject::title() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return { }; |
| |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(*node); input && input->isTextButton()) |
| return input->valueWithDefault(); |
| |
| if (isLabelable()) { |
| auto labels = Accessibility::labelsForElement(element()); |
| // Use the label text as the title if there's no ARIA override. |
| if (!labels.isEmpty() && !ariaAccessibilityDescription().length()) |
| return textForLabelElements(WTFMove(labels)); |
| } |
| |
| // For <select> elements, title should be empty if they are not rendered or have role PopUpButton. |
| if (WebCore::elementName(*node) == ElementName::HTML_select |
| && (!isAccessibilityRenderObject() || role() == AccessibilityRole::PopUpButton)) |
| return { }; |
| |
| switch (role()) { |
| case AccessibilityRole::Button: |
| case AccessibilityRole::Checkbox: |
| case AccessibilityRole::ListBoxOption: |
| case AccessibilityRole::ListItem: |
| case AccessibilityRole::MenuItem: |
| case AccessibilityRole::MenuItemCheckbox: |
| case AccessibilityRole::MenuItemRadio: |
| case AccessibilityRole::PopUpButton: |
| case AccessibilityRole::RadioButton: |
| case AccessibilityRole::Switch: |
| case AccessibilityRole::Tab: |
| case AccessibilityRole::ToggleButton: |
| return textUnderElement(); |
| // SVGRoots should not use the text under itself as a title. That could include the text of objects like <text>. |
| case AccessibilityRole::SVGRoot: |
| return String(); |
| default: |
| break; |
| } |
| |
| if (isLink()) |
| return textUnderElement(); |
| if (isHeading()) |
| return textUnderElement({ TextUnderElementMode::Children::SkipIgnoredChildren, true }); |
| |
| return { }; |
| } |
| |
| String AccessibilityNodeObject::text() const |
| { |
| if (isSecureField()) |
| return secureFieldValue(); |
| |
| // Static text can be either an element with role="text", aka ARIA static text, or inline rendered text. |
| // In the former case, prefer any alt text that may have been specified. |
| // If no alt text is present, fallback to the inline static text case where textUnderElement is used. |
| if (isARIAStaticText()) { |
| Vector<AccessibilityText> textOrder; |
| alternativeText(textOrder); |
| if (textOrder.size() > 0 && textOrder[0].text.length()) |
| return textOrder[0].text; |
| } |
| |
| if (role() == AccessibilityRole::StaticText) |
| return textUnderElement(); |
| |
| if (!isTextControl()) |
| return { }; |
| |
| RefPtr element = dynamicDowncast<Element>(node()); |
| if (RefPtr formControl = dynamicDowncast<HTMLTextFormControlElement>(element); formControl && isNativeTextControl()) |
| return formControl->value(); |
| return element ? element->innerText() : String(); |
| } |
| |
| String AccessibilityNodeObject::stringValue() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return { }; |
| |
| if (isARIAStaticText()) { |
| String staticText = text(); |
| if (!staticText.length()) |
| staticText = textUnderElement(); |
| return staticText; |
| } |
| |
| if (node->isTextNode()) |
| return textUnderElement(); |
| |
| if (RefPtr selectElement = dynamicDowncast<HTMLSelectElement>(*node)) { |
| int selectedIndex = selectElement->selectedIndex(); |
| auto& listItems = selectElement->listItems(); |
| if (selectedIndex >= 0 && static_cast<size_t>(selectedIndex) < listItems.size()) { |
| if (RefPtr selectedItem = listItems[selectedIndex].get()) { |
| auto overriddenDescription = selectedItem->attributeTrimmedWithDefaultARIA(aria_labelAttr); |
| if (!overriddenDescription.isEmpty()) |
| return overriddenDescription; |
| } |
| } |
| if (!selectElement->multiple()) |
| return selectElement->value(); |
| return { }; |
| } |
| |
| if (isComboBox()) { |
| for (const auto& child : const_cast<AccessibilityNodeObject*>(this)->unignoredChildren()) { |
| if (!child->isListBox()) |
| continue; |
| |
| if (auto selectedChildren = child->selectedChildren(); selectedChildren.size()) |
| return selectedChildren.first()->stringValue(); |
| break; |
| } |
| } |
| |
| if (isTextControl()) |
| return text(); |
| |
| // FIXME: We might need to implement a value here for more types |
| // FIXME: It would be better not to advertise a value at all for the types for which we don't implement one; |
| // this would require subclassing or making accessibilityAttributeNames do something other than return a |
| // single static array. |
| return { }; |
| } |
| |
| WallTime AccessibilityNodeObject::dateTimeValue() const |
| { |
| if (!isDateTime()) |
| return { }; |
| |
| RefPtr input = dynamicDowncast<HTMLInputElement>(node()); |
| return input ? input->accessibilityValueAsDate() : WallTime(); |
| } |
| |
| DateComponentsType AccessibilityObject::dateTimeComponentsType() const |
| { |
| if (!isDateTime()) |
| return DateComponentsType::Invalid; |
| |
| auto* input = dynamicDowncast<HTMLInputElement>(node()); |
| return input ? input->dateType() : DateComponentsType::Invalid; |
| } |
| |
| SRGBA<uint8_t> AccessibilityNodeObject::colorValue() const |
| { |
| if (!isColorWell()) |
| return Color::black; |
| |
| RefPtr input = dynamicDowncast<HTMLInputElement>(node()); |
| if (!input) |
| return Color::black; |
| |
| return input->valueAsColor().toColorTypeLossy<SRGBA<uint8_t>>(); |
| } |
| |
| // This function implements the ARIA accessible name as described by the Mozilla |
| // ARIA Implementer's Guide. |
| static String accessibleNameForNode(Node& node, Node* labelledbyNode) |
| { |
| auto* element = dynamicDowncast<Element>(node); |
| |
| auto ariaLabel = element ? element->attributeTrimmedWithDefaultARIA(aria_labelAttr) : nullAtom(); |
| if (!ariaLabel.isEmpty()) |
| return ariaLabel; |
| |
| const AtomString& alt = element ? element->attributeWithoutSynchronization(altAttr) : nullAtom(); |
| if (!alt.isEmpty()) |
| return alt; |
| |
| // If the node can be turned into an AX object, we can use standard name computation rules. |
| // If however, the node cannot (because there's no renderer e.g.) fallback to using the basic text underneath. |
| auto* cache = node.document().axObjectCache(); |
| RefPtr axObject = cache ? cache->getOrCreate(node) : nullptr; |
| if (axObject) { |
| String valueDescription = axObject->valueDescription(); |
| if (!valueDescription.isEmpty()) |
| return valueDescription; |
| |
| // The Accname specification states that if the name is being calculated for a combobox |
| // or listbox inside a labeling element, return the text alternative of the chosen option. |
| AXCoreObject::AccessibilityChildrenVector selectedChildren; |
| if (axObject->isListBox()) |
| selectedChildren = axObject->selectedChildren(); |
| else if (axObject->isComboBox()) { |
| for (const auto& child : axObject->unignoredChildren()) { |
| if (child->isListBox()) { |
| selectedChildren = child->selectedChildren(); |
| break; |
| } |
| } |
| } |
| |
| StringBuilder builder; |
| for (const auto& child : selectedChildren) |
| appendNameToStringBuilder(builder, accessibleNameForNode(*child->node())); |
| |
| String childText = builder.toString(); |
| if (!childText.isEmpty()) |
| return childText; |
| } |
| |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(element)) { |
| String inputValue = input->value(); |
| if (input->isPasswordField()) { |
| StringBuilder passwordValue; |
| passwordValue.reserveCapacity(inputValue.length()); |
| for (size_t i = 0; i < inputValue.length(); i++) |
| passwordValue.append(String::fromUTF8("•")); |
| return passwordValue.toString(); |
| } |
| return inputValue; |
| } |
| if (RefPtr option = dynamicDowncast<HTMLOptionElement>(element)) |
| return option->value(); |
| |
| String text; |
| if (axObject) { |
| if (axObject->accessibleNameDerivesFromContent()) |
| text = axObject->textUnderElement({ TextUnderElementMode::Children::IncludeNameFromContentsChildren, true, true, false, TrimWhitespace::Yes, labelledbyNode }); |
| } else |
| text = (element ? element->innerText() : node.textContent()).simplifyWhiteSpace(isASCIIWhitespace); |
| |
| if (!text.isEmpty()) |
| return text; |
| |
| const AtomString& title = element ? element->attributeWithoutSynchronization(titleAttr) : nullAtom(); |
| if (!title.isEmpty()) |
| return title; |
| |
| auto* slotElement = dynamicDowncast<HTMLSlotElement>(node); |
| // Compute the accessible name for a slot's contents only if it's being used to label another node. |
| if (auto* assignedNodes = (slotElement && labelledbyNode) ? slotElement->assignedNodes() : nullptr) { |
| StringBuilder builder; |
| for (const auto& assignedNode : *assignedNodes) |
| appendNameToStringBuilder(builder, accessibleNameForNode(*assignedNode)); |
| |
| auto assignedNodesText = builder.toString(); |
| if (!assignedNodesText.isEmpty()) |
| return assignedNodesText; |
| } |
| |
| return { }; |
| } |
| |
| String AccessibilityNodeObject::accessibilityDescriptionForChildren() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return String(); |
| |
| AXObjectCache* cache = axObjectCache(); |
| if (!cache) |
| return String(); |
| |
| StringBuilder builder; |
| for (RefPtr child = node->firstChild(); child; child = child->nextSibling()) { |
| if (!is<Element>(child)) |
| continue; |
| |
| if (RefPtr axObject = cache->getOrCreate(*child)) { |
| String description = axObject->ariaLabeledByAttribute(); |
| if (description.isEmpty()) |
| description = accessibleNameForNode(*child); |
| appendNameToStringBuilder(builder, WTFMove(description)); |
| } |
| } |
| |
| return builder.toString(); |
| } |
| |
| String AccessibilityNodeObject::descriptionForElements(const Vector<Ref<Element>>& elements) const |
| { |
| StringBuilder builder; |
| for (auto& element : elements) |
| appendNameToStringBuilder(builder, accessibleNameForNode(element.get(), node())); |
| return builder.toString(); |
| } |
| |
| String AccessibilityNodeObject::ariaDescribedByAttribute() const |
| { |
| return descriptionForElements(elementsFromAttribute(aria_describedbyAttr)); |
| } |
| |
| Vector<Ref<Element>> AccessibilityNodeObject::ariaLabeledByElements() const |
| { |
| // FIXME: should walk the DOM elements only once. |
| auto elements = elementsFromAttribute(aria_labelledbyAttr); |
| if (elements.size()) |
| return elements; |
| return elementsFromAttribute(aria_labeledbyAttr); |
| } |
| |
| |
| String AccessibilityNodeObject::ariaLabeledByAttribute() const |
| { |
| return descriptionForElements(ariaLabeledByElements()); |
| } |
| |
| bool AccessibilityNodeObject::hasAccNameAttribute() const |
| { |
| RefPtr element = this->element(); |
| return element && WebCore::hasAccNameAttribute(*element); |
| } |
| |
| bool AccessibilityNodeObject::hasAttributesRequiredForInclusion() const |
| { |
| RefPtr element = this->element(); |
| if (!element) |
| return false; |
| |
| if (WebCore::hasAccNameAttribute(*element)) |
| return true; |
| |
| #if ENABLE(MATHML) |
| if (element->attributeWithoutSynchronization(MathMLNames::alttextAttr).length()) |
| return true; |
| #endif |
| |
| if (element->attributeWithoutSynchronization(altAttr).length()) |
| return true; |
| |
| if (element->attributeWithoutSynchronization(aria_helpAttr).length()) [[unlikely]] |
| return true; |
| |
| return false; |
| } |
| |
| bool AccessibilityNodeObject::isFocused() const |
| { |
| if (!m_node) |
| return false; |
| |
| Ref document = node()->document(); |
| RefPtr focusedElement = document->focusedElement(); |
| if (!focusedElement) |
| return false; |
| |
| if (focusedElement.get() == node()) |
| return true; |
| |
| // A web area is represented by the Document node in the DOM tree which isn't focusable. |
| // Instead, check if the frame's selection is focused. |
| if (role() != AccessibilityRole::WebArea) |
| return false; |
| |
| RefPtr frame = document->frame(); |
| return frame ? frame->selection().isFocusedAndActive() : false; |
| } |
| |
| void AccessibilityNodeObject::setFocused(bool on) |
| { |
| // Call the base class setFocused to ensure the view is focused and active. |
| AccessibilityObject::setFocused(on); |
| |
| if (!canSetFocusAttribute()) |
| return; |
| |
| RefPtr document = this->document(); |
| |
| // This is needed or else focus won't always go into iframes with different origins. |
| UserGestureIndicator gestureIndicator(IsProcessingUserGesture::Yes, document.get()); |
| |
| // Handle clearing focus. |
| if (!on || !is<Element>(node())) { |
| document->setFocusedElement(nullptr); |
| return; |
| } |
| |
| // When a node is told to set focus, that can cause it to be deallocated, which means that doing |
| // anything else inside this object will crash. To fix this, we added a RefPtr to protect this object |
| // long enough for duration. |
| RefPtr<AccessibilityObject> protectedThis(this); |
| |
| // If this node is already the currently focused node, then calling focus() won't do anything. |
| // That is a problem when focus is removed from the webpage to chrome, and then returns. |
| // In these cases, we need to do what keyboard and mouse focus do, which is reset focus first. |
| if (document->focusedElement() == node()) |
| document->setFocusedElement(nullptr); |
| |
| // If we return from setFocusedElement and our element has been removed from a tree, axObjectCache() may be null. |
| if (auto* cache = axObjectCache()) { |
| cache->setIsSynchronizingSelection(true); |
| downcast<Element>(*m_node).focus(); |
| cache->setIsSynchronizingSelection(false); |
| } |
| } |
| |
| bool AccessibilityNodeObject::canSetFocusAttribute() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return false; |
| |
| if (isWebArea()) |
| return true; |
| |
| // NOTE: It would be more accurate to ask the document whether setFocusedElement() would |
| // do anything. For example, setFocusedElement() will do nothing if the current focused |
| // node will not relinquish the focus. |
| RefPtr element = dynamicDowncast<Element>(*node); |
| return element && !element->isDisabledFormControl() && element->supportsFocus(); |
| } |
| |
| bool AccessibilityNodeObject::canSetValueAttribute() const |
| { |
| RefPtr node = this->node(); |
| if (!node) |
| return false; |
| |
| // The host-language readonly attribute trumps aria-readonly. |
| if (RefPtr textarea = dynamicDowncast<HTMLTextAreaElement>(*node)) |
| return !textarea->isReadOnly(); |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(*node); input && input->isTextField()) |
| return !input->isReadOnly(); |
| |
| String readOnly = readOnlyValue(); |
| if (!readOnly.isEmpty()) |
| return readOnly == "true"_s ? false : true; |
| |
| if (isNonNativeTextControl()) |
| return true; |
| |
| if (isMeter()) |
| return false; |
| |
| if (isProgressIndicator() || isSlider() || isScrollbar()) |
| return true; |
| |
| #if USE(ATSPI) |
| // In ATSPI, input types which support aria-readonly are treated as having a |
| // settable value if the user can modify the widget's value or its state. |
| if (supportsReadOnly()) |
| return true; |
| |
| if (isRadioButton()) { |
| auto radioGroup = radioGroupAncestor(); |
| return radioGroup ? radioGroup->readOnlyValue() != "true"_s : true; |
| } |
| #endif |
| |
| if (isWebArea()) { |
| RefPtr document = this->document(); |
| if (!document) |
| return false; |
| |
| if (RefPtr body = document->bodyOrFrameset()) { |
| if (body->hasEditableStyle()) |
| return true; |
| } |
| |
| return document->hasEditableStyle(); |
| } |
| |
| return node->hasEditableStyle(); |
| } |
| |
| AccessibilityRole AccessibilityNodeObject::determineAriaRoleAttribute() const |
| { |
| const AtomString& ariaRole = getAttribute(roleAttr); |
| if (ariaRole.isNull() || ariaRole.isEmpty()) |
| return AccessibilityRole::Unknown; |
| |
| AccessibilityRole role = ariaRoleToWebCoreRole(ariaRole); |
| |
| // ARIA states if an item can get focus, it should not be presentational. |
| if (role == AccessibilityRole::Presentational && canSetFocusAttribute()) |
| return AccessibilityRole::Unknown; |
| |
| if (role == AccessibilityRole::Button) |
| role = buttonRoleType(); |
| |
| // If ariaRoleToWebCoreRole computed AccessibilityRole::TextField, we need to figure out if we should use the single-line WebCore textbox role (AccessibilityRole::TextField) |
| // or the multi-line WebCore textbox role (AccessibilityRole::TextArea) because the "textbox" ARIA role is overloaded and can mean either. |
| if (role == AccessibilityRole::TextField) { |
| auto ariaMultiline = getAttribute(aria_multilineAttr); |
| if (equalLettersIgnoringASCIICase(ariaMultiline, "true"_s) || (!equalLettersIgnoringASCIICase(ariaMultiline, "false"_s) && matchesTextAreaRole())) |
| role = AccessibilityRole::TextArea; |
| } |
| |
| role = remapAriaRoleDueToParent(role); |
| |
| // Presentational roles are invalidated by the presence of ARIA attributes. |
| if (role == AccessibilityRole::Presentational && supportsARIAAttributes()) |
| role = AccessibilityRole::Unknown; |
| |
| // https://w3c.github.io/aria/#document-handling_author-errors_roles |
| // In situations where an author has not specified names for the form and |
| // region landmarks, it is considered an authoring error. The user agent |
| // MUST treat such element as if no role had been provided. |
| if ((role == AccessibilityRole::LandmarkRegion || role == AccessibilityRole::Form) && !hasAccNameAttribute()) { |
| // If a region has no label, but it does have a fallback role, use that instead. |
| auto nextRole = ariaRoleToWebCoreRole(ariaRole, [] (const AccessibilityRole& skipRole) { |
| return skipRole == AccessibilityRole::LandmarkRegion; |
| }); |
| if (nextRole != role) |
| role = nextRole; |
| else |
| role = AccessibilityRole::Unknown; |
| } |
| if (enumToUnderlyingType(role)) |
| return role; |
| |
| return AccessibilityRole::Unknown; |
| } |
| |
| AccessibilityRole AccessibilityNodeObject::remapAriaRoleDueToParent(AccessibilityRole role) const |
| { |
| // Some objects change their role based on their parent. |
| // However, asking for the unignoredParent calls isIgnored(), which can trigger a loop. |
| // While inside the call stack of creating an element, we need to avoid isIgnored(). |
| // https://bugs.webkit.org/show_bug.cgi?id=65174 |
| |
| if (role != AccessibilityRole::ListBoxOption && role != AccessibilityRole::MenuItem) |
| return role; |
| |
| for (RefPtr parent = parentObject(); parent && !parent->isIgnored(); parent = parent->parentObject()) { |
| AccessibilityRole parentAriaRole = parent->ariaRoleAttribute(); |
| |
| // Selects and listboxes both have options as child roles, but they map to different roles within WebCore. |
| if (role == AccessibilityRole::ListBoxOption && parentAriaRole == AccessibilityRole::Menu) |
| return AccessibilityRole::MenuItem; |
| |
| // If the parent had a different role, then we don't need to continue searching up the chain. |
| if (parentAriaRole != AccessibilityRole::Unknown) |
| break; |
| } |
| |
| return role; |
| } |
| |
| bool AccessibilityNodeObject::canSetSelectedAttribute() const |
| { |
| if (isColumnHeader()) |
| return false; |
| |
| if (isRowHeader() && isEnabled()) |
| return true; |
| |
| // Elements that can be selected |
| switch (role()) { |
| case AccessibilityRole::Cell: |
| case AccessibilityRole::GridCell: |
| case AccessibilityRole::Row: |
| case AccessibilityRole::TabList: |
| case AccessibilityRole::Tab: |
| case AccessibilityRole::TreeGrid: |
| case AccessibilityRole::TreeItem: |
| case AccessibilityRole::Tree: |
| case AccessibilityRole::MenuItemCheckbox: |
| case AccessibilityRole::MenuItemRadio: |
| case AccessibilityRole::MenuItem: |
| return isEnabled(); |
| default: |
| return false; |
| } |
| } |
| |
| namespace Accessibility { |
| |
| RefPtr<HTMLElement> controlForLabelElement(const HTMLLabelElement& label) |
| { |
| auto control = label.control(); |
| // Make sure the corresponding control isn't a descendant of this label that's in the middle of being destroyed. |
| if (!control || (control->renderer() && !control->renderer()->parent())) |
| return nullptr; |
| return control; |
| } |
| |
| Vector<Ref<HTMLElement>> labelsForElement(Element* element) |
| { |
| RefPtr htmlElement = dynamicDowncast<HTMLElement>(element); |
| if (!htmlElement || !htmlElement->isLabelable()) |
| return { }; |
| |
| Vector<Ref<HTMLElement>> result; |
| const auto& idAttribute = htmlElement->getIdAttribute(); |
| if (!idAttribute.isEmpty()) { |
| if (htmlElement->hasAttributeWithoutSynchronization(aria_labelAttr)) |
| return { }; |
| |
| if (auto* treeScopeLabels = htmlElement->treeScope().labelElementsForId(idAttribute); treeScopeLabels && !treeScopeLabels->isEmpty()) { |
| result.appendVector(WTF::compactMap(*treeScopeLabels, [] (auto& label) { |
| return RefPtr { dynamicDowncast<HTMLLabelElement>(label.get()) }; |
| })); |
| if (result.size()) |
| return result; |
| } |
| } |
| |
| if (htmlElement->hasAttributeWithoutSynchronization(aria_labelAttr)) |
| return { }; |
| |
| if (RefPtr nearestLabel = ancestorsOfType<HTMLLabelElement>(*htmlElement).first()) { |
| // Only use the nearest label if it isn't pointing at something else. |
| const auto& forAttribute = nearestLabel->attributeWithoutSynchronization(forAttr); |
| if (forAttribute.isEmpty() || forAttribute == idAttribute) |
| return { nearestLabel.releaseNonNull() }; |
| } |
| return { }; |
| } |
| |
| } // namespace Accessibility |
| |
| } // namespace WebCore |